@lownoise-studio/rendershield 0.1.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/DEPLOY.md +185 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/cli.js +50 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/build.js +58 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/init.js +102 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/verify.js +100 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/core/generateRobots.js +14 -0
- package/dist/core/generateRobots.js.map +1 -0
- package/dist/core/generateSitemap.js +31 -0
- package/dist/core/generateSitemap.js.map +1 -0
- package/dist/core/generateWorker.js +77 -0
- package/dist/core/generateWorker.js.map +1 -0
- package/dist/core/loadConfig.js +60 -0
- package/dist/core/loadConfig.js.map +1 -0
- package/dist/core/loadMarkdown.js +63 -0
- package/dist/core/loadMarkdown.js.map +1 -0
- package/dist/core/renderHtml.js +69 -0
- package/dist/core/renderHtml.js.map +1 -0
- package/dist/core/validateOutput.js +121 -0
- package/dist/core/validateOutput.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/deploy-cloudflare.md +204 -0
- package/package.json +43 -0
- package/src/cli.ts +54 -0
- package/src/commands/build.ts +72 -0
- package/src/commands/init.ts +112 -0
- package/src/commands/verify.ts +114 -0
- package/src/core/generateRobots.ts +18 -0
- package/src/core/generateSitemap.ts +38 -0
- package/src/core/generateWorker.ts +80 -0
- package/src/core/loadConfig.ts +74 -0
- package/src/core/loadMarkdown.ts +82 -0
- package/src/core/renderHtml.ts +76 -0
- package/src/core/validateOutput.ts +148 -0
- package/src/types/markdown-it.d.ts +20 -0
- package/src/types.ts +52 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Deploying RenderShield with Cloudflare
|
|
2
|
+
|
|
3
|
+
This document explains how to deploy RenderShield in front of an existing
|
|
4
|
+
single-page application (SPA) using Cloudflare.
|
|
5
|
+
|
|
6
|
+
The behavior is intentionally simple:
|
|
7
|
+
|
|
8
|
+
- Humans receive the normal SPA
|
|
9
|
+
- Crawlers receive prerendered static HTML
|
|
10
|
+
|
|
11
|
+
RenderShield does not execute JavaScript for bots and does not alter crawler
|
|
12
|
+
behavior. It only guarantees that complete HTML exists and is served when
|
|
13
|
+
appropriate.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
You will need:
|
|
20
|
+
|
|
21
|
+
- A Cloudflare account
|
|
22
|
+
- A domain using Cloudflare nameservers
|
|
23
|
+
- An existing SPA hosted somewhere (Lovable, Vercel, Netlify, etc.)
|
|
24
|
+
- RenderShield build output (dist-prerender/)
|
|
25
|
+
|
|
26
|
+
RenderShield does not host your application. It only controls routing for
|
|
27
|
+
crawler requests.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Architecture overview
|
|
32
|
+
|
|
33
|
+
At a high level:
|
|
34
|
+
|
|
35
|
+
- Cloudflare proxies all traffic for your domain
|
|
36
|
+
- A Worker inspects incoming requests
|
|
37
|
+
- Known crawlers are routed to prerendered HTML files
|
|
38
|
+
- All other traffic passes through to your SPA unchanged
|
|
39
|
+
|
|
40
|
+
If prerendered output is missing or incomplete, RenderShield fails the build.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## DNS configuration
|
|
45
|
+
|
|
46
|
+
In Cloudflare → DNS, create a single record:
|
|
47
|
+
|
|
48
|
+
- Type: CNAME
|
|
49
|
+
- Name: @ (or www if applicable)
|
|
50
|
+
- Target: your SPA origin (for example: example.lovable.app)
|
|
51
|
+
- Proxy status: ON
|
|
52
|
+
|
|
53
|
+
The proxy must be enabled.
|
|
54
|
+
If it is disabled, the Worker will never execute.
|
|
55
|
+
|
|
56
|
+
### DNS sanity check
|
|
57
|
+
|
|
58
|
+
Cloudflare may import old records when you add a domain.
|
|
59
|
+
|
|
60
|
+
If you see A records pointing to IPs you do not recognize, remove them.
|
|
61
|
+
A minimal setup is preferred: one proxied CNAME.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## SSL configuration
|
|
66
|
+
|
|
67
|
+
In Cloudflare → SSL/TLS:
|
|
68
|
+
|
|
69
|
+
- Set SSL mode to Full or Full (strict)
|
|
70
|
+
|
|
71
|
+
Avoid Flexible.
|
|
72
|
+
Flexible SSL commonly causes redirect loops between Cloudflare and your origin.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Worker deployment
|
|
77
|
+
|
|
78
|
+
1) Go to Workers & Pages in the Cloudflare dashboard
|
|
79
|
+
2) Create a new Worker
|
|
80
|
+
3) Paste the contents of: dist-prerender/worker.js
|
|
81
|
+
4) Save and deploy
|
|
82
|
+
|
|
83
|
+
The Worker does not require access to your application source code.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Worker routing (required)
|
|
88
|
+
|
|
89
|
+
Attach the Worker to your domain by adding a route:
|
|
90
|
+
|
|
91
|
+
- yourdomain.com/*
|
|
92
|
+
|
|
93
|
+
If you use www, also add:
|
|
94
|
+
|
|
95
|
+
- www.yourdomain.com/*
|
|
96
|
+
|
|
97
|
+
Without a route, the Worker will never run.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Hosting prerendered output
|
|
102
|
+
|
|
103
|
+
The Worker must be able to fetch prerendered files.
|
|
104
|
+
|
|
105
|
+
You need a static origin that serves paths like:
|
|
106
|
+
|
|
107
|
+
- /content/example/index.html
|
|
108
|
+
- /sitemap.xml
|
|
109
|
+
- /robots.txt
|
|
110
|
+
|
|
111
|
+
Common options include:
|
|
112
|
+
|
|
113
|
+
- Cloudflare Pages
|
|
114
|
+
- Any static file host
|
|
115
|
+
- Your existing host (if it supports static files)
|
|
116
|
+
|
|
117
|
+
Requirement:
|
|
118
|
+
A request for a prerendered path must return the HTML file directly.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Sitemap and robots
|
|
123
|
+
|
|
124
|
+
Ensure these files are publicly accessible:
|
|
125
|
+
|
|
126
|
+
- https://yourdomain.com/sitemap.xml
|
|
127
|
+
- https://yourdomain.com/robots.txt
|
|
128
|
+
|
|
129
|
+
Submit the sitemap URL in Google Search Console.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Verification
|
|
134
|
+
|
|
135
|
+
RenderShield includes a verify command to guide testing.
|
|
136
|
+
|
|
137
|
+
### Worker execution test
|
|
138
|
+
|
|
139
|
+
Run:
|
|
140
|
+
|
|
141
|
+
curl -I -H "User-Agent: GPTBot" https://yourdomain.com/content/example
|
|
142
|
+
|
|
143
|
+
If debug headers are enabled, you should see headers similar to:
|
|
144
|
+
|
|
145
|
+
- X-Bot-Detected: true
|
|
146
|
+
- X-Prerender: true
|
|
147
|
+
- X-Final-Path: /content/example/index.html
|
|
148
|
+
|
|
149
|
+
If these headers are missing:
|
|
150
|
+
- The Worker route may not be attached
|
|
151
|
+
- The Cloudflare proxy may be disabled
|
|
152
|
+
- DNS may still point to a different origin
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Content comparison
|
|
157
|
+
|
|
158
|
+
Human request (SPA response):
|
|
159
|
+
|
|
160
|
+
curl -s https://yourdomain.com/content/example | grep -i "<title>"
|
|
161
|
+
|
|
162
|
+
Crawler request (prerendered HTML):
|
|
163
|
+
|
|
164
|
+
curl -s -H "User-Agent: Googlebot" https://yourdomain.com/content/example | grep -i "<title>"
|
|
165
|
+
|
|
166
|
+
The crawler response should contain the prerendered, route-specific title.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Common issues
|
|
171
|
+
|
|
172
|
+
### Worker does not appear to run
|
|
173
|
+
|
|
174
|
+
Symptoms:
|
|
175
|
+
- Site loads normally in a browser
|
|
176
|
+
- Crawlers still receive the SPA shell
|
|
177
|
+
|
|
178
|
+
Check:
|
|
179
|
+
- Worker route exists
|
|
180
|
+
- DNS proxy is enabled
|
|
181
|
+
|
|
182
|
+
### 421 errors from origin
|
|
183
|
+
|
|
184
|
+
This typically indicates a Host header mismatch.
|
|
185
|
+
|
|
186
|
+
Ensure the Worker constructs a fresh request to the origin rather than
|
|
187
|
+
forwarding incoming request headers directly.
|
|
188
|
+
|
|
189
|
+
### Redirect loops
|
|
190
|
+
|
|
191
|
+
Symptoms:
|
|
192
|
+
- Browser reports “too many redirects”
|
|
193
|
+
|
|
194
|
+
Fix:
|
|
195
|
+
- Set SSL mode to Full or Full (strict)
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Final note
|
|
200
|
+
|
|
201
|
+
RenderShield does not manipulate rankings, indexing, or crawler behavior.
|
|
202
|
+
|
|
203
|
+
It guarantees one thing only:
|
|
204
|
+
that crawlers receive complete, static HTML instead of an empty shell.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lownoise-studio/rendershield",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Boring bot-aware prerendering: real HTML for bots, SPA for humans.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rendershield": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json",
|
|
12
|
+
"dev": "node --enable-source-maps dist/cli.js",
|
|
13
|
+
"start": "node dist/cli.js",
|
|
14
|
+
"prepack": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/**",
|
|
18
|
+
"src/**",
|
|
19
|
+
"docs/**",
|
|
20
|
+
"content/**",
|
|
21
|
+
"DEPLOY.md",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Lownoise-Studio/rendershield.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/Lownoise-Studio/rendershield#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/Lownoise-Studio/rendershield/issues"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"fast-glob": "^3.3.2",
|
|
35
|
+
"fs-extra": "^11.2.0",
|
|
36
|
+
"gray-matter": "^4.0.3",
|
|
37
|
+
"markdown-it": "^14.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/fs-extra": "^11.0.4",
|
|
41
|
+
"typescript": "^5.5.4"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cmdInit } from "./commands/init.js";
|
|
3
|
+
import { cmdBuild } from "./commands/build.js";
|
|
4
|
+
import { cmdVerify } from "./commands/verify.js";
|
|
5
|
+
|
|
6
|
+
function printHelp() {
|
|
7
|
+
console.log(`
|
|
8
|
+
RenderShield (v0) — boring bot-aware prerendering.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
rendershield init
|
|
12
|
+
rendershield build
|
|
13
|
+
rendershield verify
|
|
14
|
+
|
|
15
|
+
Notes:
|
|
16
|
+
- Config file: rendershield.config.json
|
|
17
|
+
- Content: content/blog/*.md (frontmatter required)
|
|
18
|
+
- Output: dist-prerender/
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
const cmd = process.argv[2]?.trim();
|
|
24
|
+
|
|
25
|
+
if (!cmd || cmd === "-h" || cmd === "--help") {
|
|
26
|
+
printHelp();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
if (cmd === "init") {
|
|
32
|
+
await cmdInit();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (cmd === "build") {
|
|
36
|
+
await cmdBuild();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (cmd === "verify") {
|
|
40
|
+
await cmdVerify();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
45
|
+
printHelp();
|
|
46
|
+
process.exit(1);
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
const msg = err?.message ? String(err.message) : String(err);
|
|
49
|
+
console.error(`\nRenderShield error: ${msg}\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
+
import { loadAllMarkdownDocs } from "../core/loadMarkdown.js";
|
|
5
|
+
import { renderPageHtml } from "../core/renderHtml.js";
|
|
6
|
+
import { generateSitemapXml } from "../core/generateSitemap.js";
|
|
7
|
+
import { generateRobotsTxt } from "../core/generateRobots.js";
|
|
8
|
+
import { generateWorkerJs } from "../core/generateWorker.js";
|
|
9
|
+
import { validatePrerenderHtml } from "../core/validateOutput.js";
|
|
10
|
+
|
|
11
|
+
function routeToOutDir(outDirAbs: string, routePath: string): string {
|
|
12
|
+
// /blog/slug -> outDir/blog/slug/index.html
|
|
13
|
+
const clean = routePath.replace(/^\//, "");
|
|
14
|
+
return path.join(outDirAbs, clean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function cmdBuild(cwd = process.cwd()) {
|
|
18
|
+
const cfg = await loadConfig(cwd);
|
|
19
|
+
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
20
|
+
|
|
21
|
+
// Clean output (boring + deterministic)
|
|
22
|
+
await fs.remove(outDirAbs);
|
|
23
|
+
await fs.ensureDir(outDirAbs);
|
|
24
|
+
|
|
25
|
+
const docs = await loadAllMarkdownDocs(cfg, cwd);
|
|
26
|
+
|
|
27
|
+
if (docs.length === 0) {
|
|
28
|
+
throw new Error("No markdown documents found. Check content paths/patterns.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Generate pages (validate BEFORE writing)
|
|
32
|
+
for (const doc of docs) {
|
|
33
|
+
const pageDir = routeToOutDir(outDirAbs, doc.routePath);
|
|
34
|
+
await fs.ensureDir(pageDir);
|
|
35
|
+
|
|
36
|
+
const outFile = path.join(pageDir, "index.html");
|
|
37
|
+
const html = renderPageHtml(cfg, doc);
|
|
38
|
+
|
|
39
|
+
validatePrerenderHtml({
|
|
40
|
+
html,
|
|
41
|
+
outFile,
|
|
42
|
+
routePath: doc.routePath,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await fs.writeFile(outFile, html, "utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// sitemap.xml
|
|
49
|
+
if (cfg.sitemap.enabled) {
|
|
50
|
+
const sitemapXml = generateSitemapXml(cfg, docs);
|
|
51
|
+
const sitemapPath = path.join(outDirAbs, cfg.sitemap.path.replace(/^\//, ""));
|
|
52
|
+
await fs.writeFile(sitemapPath, sitemapXml, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// robots.txt
|
|
56
|
+
if (cfg.robots.enabled) {
|
|
57
|
+
const robotsTxt = generateRobotsTxt(cfg);
|
|
58
|
+
const robotsPath = path.join(outDirAbs, cfg.robots.path.replace(/^\//, ""));
|
|
59
|
+
await fs.writeFile(robotsPath, robotsTxt, "utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// worker.js
|
|
63
|
+
if (cfg.worker.enabled) {
|
|
64
|
+
const workerJs = generateWorkerJs(cfg);
|
|
65
|
+
await fs.writeFile(path.join(outDirAbs, "worker.js"), workerJs, "utf8");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(`Built ${docs.length} pages into ${cfg.output.outDir}/`);
|
|
69
|
+
console.log(
|
|
70
|
+
`Output includes: ${cfg.sitemap.enabled ? "sitemap.xml " : ""}${cfg.robots.enabled ? "robots.txt " : ""}${cfg.worker.enabled ? "worker.js" : ""}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const CONFIG_NAME = "rendershield.config.json";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG = {
|
|
7
|
+
version: 1,
|
|
8
|
+
site: {
|
|
9
|
+
canonicalBase: "https://example.com",
|
|
10
|
+
siteName: "Example Site",
|
|
11
|
+
defaultOgImage: "https://example.com/og/default.jpg",
|
|
12
|
+
authorName: "Your Name"
|
|
13
|
+
},
|
|
14
|
+
content: {
|
|
15
|
+
markdown: {
|
|
16
|
+
baseDir: "content",
|
|
17
|
+
collections: [
|
|
18
|
+
{
|
|
19
|
+
name: "pages",
|
|
20
|
+
pattern: "pages/**/*.md",
|
|
21
|
+
routeBase: "/pages",
|
|
22
|
+
schemaType: "Article"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
output: {
|
|
28
|
+
outDir: "dist-prerender",
|
|
29
|
+
prettyHtml: true
|
|
30
|
+
},
|
|
31
|
+
sitemap: {
|
|
32
|
+
enabled: true,
|
|
33
|
+
path: "/sitemap.xml"
|
|
34
|
+
},
|
|
35
|
+
robots: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
path: "/robots.txt"
|
|
38
|
+
},
|
|
39
|
+
worker: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
lovableOrigin: "https://YOUR_SITE.lovable.app",
|
|
42
|
+
rewriteRouteBases: ["/pages/"],
|
|
43
|
+
botUserAgentPatterns: [
|
|
44
|
+
"googlebot",
|
|
45
|
+
"bingbot",
|
|
46
|
+
"gptbot",
|
|
47
|
+
"claudebot",
|
|
48
|
+
"perplexitybot",
|
|
49
|
+
"twitterbot",
|
|
50
|
+
"facebookexternalhit",
|
|
51
|
+
"linkedinbot",
|
|
52
|
+
"slackbot",
|
|
53
|
+
"discordbot",
|
|
54
|
+
"whatsapp",
|
|
55
|
+
"telegram"
|
|
56
|
+
],
|
|
57
|
+
debugHeaders: true
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
62
|
+
|
|
63
|
+
const SAMPLE_POST = `---
|
|
64
|
+
title: Hello World
|
|
65
|
+
excerpt: This is a sample page generated by RenderShield.
|
|
66
|
+
datePublished: ${today}
|
|
67
|
+
coverImage: /images/hello.jpg
|
|
68
|
+
slug: hello-world
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
This is **RenderShield**.
|
|
72
|
+
|
|
73
|
+
- Crawlers should see real HTML.
|
|
74
|
+
- Humans keep the SPA.
|
|
75
|
+
|
|
76
|
+
That's the whole trick.
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
export async function cmdInit(cwd = process.cwd()) {
|
|
80
|
+
const configPath = path.join(cwd, CONFIG_NAME);
|
|
81
|
+
const contentDir = path.join(cwd, "content", "pages");
|
|
82
|
+
const samplePath = path.join(contentDir, "hello-world.md");
|
|
83
|
+
|
|
84
|
+
const configExists = await fs.pathExists(configPath);
|
|
85
|
+
if (!configExists) {
|
|
86
|
+
await fs.writeFile(
|
|
87
|
+
configPath,
|
|
88
|
+
JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
|
|
89
|
+
"utf8"
|
|
90
|
+
);
|
|
91
|
+
console.log(`Created ${CONFIG_NAME}`);
|
|
92
|
+
} else {
|
|
93
|
+
console.log(`${CONFIG_NAME} already exists (leaving it alone)`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await fs.ensureDir(contentDir);
|
|
97
|
+
|
|
98
|
+
const sampleExists = await fs.pathExists(samplePath);
|
|
99
|
+
if (!sampleExists) {
|
|
100
|
+
await fs.writeFile(samplePath, SAMPLE_POST, "utf8");
|
|
101
|
+
console.log(`Created sample content: content/pages/hello-world.md`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(`Sample content already exists (leaving it alone)`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`
|
|
107
|
+
Next:
|
|
108
|
+
1) Edit rendershield.config.json
|
|
109
|
+
2) Add content under content/pages/
|
|
110
|
+
3) Run: rendershield build
|
|
111
|
+
`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
+
|
|
5
|
+
function joinUrl(base: string, routePath: string): string {
|
|
6
|
+
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
7
|
+
const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
|
|
8
|
+
return b + p;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function findFirstIndexHtml(outDirAbs: string): Promise<string | null> {
|
|
12
|
+
const stack: string[] = [outDirAbs];
|
|
13
|
+
|
|
14
|
+
while (stack.length > 0) {
|
|
15
|
+
const current = stack.pop() as string;
|
|
16
|
+
|
|
17
|
+
let entries: fs.Dirent[];
|
|
18
|
+
try {
|
|
19
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const full = path.join(current, entry.name);
|
|
28
|
+
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
stack.push(full);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
|
|
35
|
+
// Ignore index.html at the output root; prefer a routed page like
|
|
36
|
+
// <section>/<slug>/index.html (or deeper).
|
|
37
|
+
const rel = path.relative(outDirAbs, full);
|
|
38
|
+
const parts = rel.split(path.sep).filter(Boolean);
|
|
39
|
+
if (parts.length >= 2) return full;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function indexHtmlPathToRoute(outDirAbs: string, indexPathAbs: string): string {
|
|
48
|
+
const rel = path.relative(outDirAbs, indexPathAbs);
|
|
49
|
+
// rel: <section>/<slug>/index.html
|
|
50
|
+
const noFile = rel.replace(/index\.html$/i, "");
|
|
51
|
+
const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
|
|
52
|
+
return "/" + normalized.replace(/^\/+/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function cmdVerify(cwd = process.cwd()) {
|
|
56
|
+
const cfg = await loadConfig(cwd);
|
|
57
|
+
|
|
58
|
+
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
59
|
+
const exists = await fs.pathExists(outDirAbs);
|
|
60
|
+
|
|
61
|
+
if (!exists) {
|
|
62
|
+
console.log(`
|
|
63
|
+
RenderShield verify
|
|
64
|
+
|
|
65
|
+
No prerender output directory found: ${cfg.output.outDir}/
|
|
66
|
+
|
|
67
|
+
Run:
|
|
68
|
+
node dist/cli.js build
|
|
69
|
+
`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const firstIndex = await findFirstIndexHtml(outDirAbs);
|
|
74
|
+
|
|
75
|
+
if (!firstIndex) {
|
|
76
|
+
console.log(`
|
|
77
|
+
RenderShield verify
|
|
78
|
+
|
|
79
|
+
No prerendered pages found inside: ${cfg.output.outDir}/
|
|
80
|
+
|
|
81
|
+
Run:
|
|
82
|
+
node dist/cli.js build
|
|
83
|
+
`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
|
|
88
|
+
const url = joinUrl(cfg.site.canonicalBase, routePath);
|
|
89
|
+
|
|
90
|
+
console.log(`
|
|
91
|
+
RenderShield verify
|
|
92
|
+
|
|
93
|
+
Using:
|
|
94
|
+
canonicalBase: ${cfg.site.canonicalBase}
|
|
95
|
+
routePath: ${routePath}
|
|
96
|
+
output file: ${path.relative(cwd, firstIndex)}
|
|
97
|
+
|
|
98
|
+
Smoke tests:
|
|
99
|
+
|
|
100
|
+
1) Human (usually SPA shell):
|
|
101
|
+
curl -s ${url} | grep -i "<title>"
|
|
102
|
+
|
|
103
|
+
2) Bot (should see prerendered, route-specific title):
|
|
104
|
+
curl -s -H "User-Agent: Googlebot" ${url} | grep -i "<title>"
|
|
105
|
+
|
|
106
|
+
3) Debug headers (Worker must be routed + proxy ON):
|
|
107
|
+
curl -I -H "User-Agent: GPTBot" ${url}
|
|
108
|
+
|
|
109
|
+
Expected headers (if debugHeaders enabled):
|
|
110
|
+
X-Bot-Detected: true
|
|
111
|
+
X-Prerender: true
|
|
112
|
+
X-Final-Path: ${routePath.replace(/\/?$/, "/index.html")}
|
|
113
|
+
`);
|
|
114
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { RenderShieldConfig } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export function generateRobotsTxt(cfg: RenderShieldConfig): string {
|
|
4
|
+
const sitemapUrl = cfg.sitemap.enabled
|
|
5
|
+
? `${cfg.site.canonicalBase.replace(/\/$/, "")}${cfg.sitemap.path}`
|
|
6
|
+
: "";
|
|
7
|
+
|
|
8
|
+
const lines = [
|
|
9
|
+
"User-agent: *",
|
|
10
|
+
"Allow: /",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
if (sitemapUrl) {
|
|
14
|
+
lines.push(`Sitemap: ${sitemapUrl}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return lines.join("\n") + "\n";
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { MarkdownDoc, RenderShieldConfig } from "../types.js";
|
|
2
|
+
|
|
3
|
+
function joinUrl(base: string, pathname: string): string {
|
|
4
|
+
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
5
|
+
const p = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
6
|
+
return b + p;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeXml(s: string): string {
|
|
10
|
+
return s
|
|
11
|
+
.replaceAll("&", "&")
|
|
12
|
+
.replaceAll("<", "<")
|
|
13
|
+
.replaceAll(">", ">")
|
|
14
|
+
.replaceAll('"', """)
|
|
15
|
+
.replaceAll("'", "'");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateSitemapXml(cfg: RenderShieldConfig, docs: MarkdownDoc[]): string {
|
|
19
|
+
const urls = docs.map((d) => ({
|
|
20
|
+
loc: joinUrl(cfg.site.canonicalBase, d.routePath),
|
|
21
|
+
lastmod: d.datePublished,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const items = urls
|
|
25
|
+
.map(
|
|
26
|
+
(u) => ` <url>
|
|
27
|
+
<loc>${escapeXml(u.loc)}</loc>
|
|
28
|
+
<lastmod>${escapeXml(u.lastmod)}</lastmod>
|
|
29
|
+
</url>`
|
|
30
|
+
)
|
|
31
|
+
.join("\n");
|
|
32
|
+
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
35
|
+
${items}
|
|
36
|
+
</urlset>
|
|
37
|
+
`;
|
|
38
|
+
}
|