@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.
Files changed (42) hide show
  1. package/DEPLOY.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -0
  4. package/dist/cli.js +50 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/build.js +58 -0
  7. package/dist/commands/build.js.map +1 -0
  8. package/dist/commands/init.js +102 -0
  9. package/dist/commands/init.js.map +1 -0
  10. package/dist/commands/verify.js +100 -0
  11. package/dist/commands/verify.js.map +1 -0
  12. package/dist/core/generateRobots.js +14 -0
  13. package/dist/core/generateRobots.js.map +1 -0
  14. package/dist/core/generateSitemap.js +31 -0
  15. package/dist/core/generateSitemap.js.map +1 -0
  16. package/dist/core/generateWorker.js +77 -0
  17. package/dist/core/generateWorker.js.map +1 -0
  18. package/dist/core/loadConfig.js +60 -0
  19. package/dist/core/loadConfig.js.map +1 -0
  20. package/dist/core/loadMarkdown.js +63 -0
  21. package/dist/core/loadMarkdown.js.map +1 -0
  22. package/dist/core/renderHtml.js +69 -0
  23. package/dist/core/renderHtml.js.map +1 -0
  24. package/dist/core/validateOutput.js +121 -0
  25. package/dist/core/validateOutput.js.map +1 -0
  26. package/dist/types.js +2 -0
  27. package/dist/types.js.map +1 -0
  28. package/docs/deploy-cloudflare.md +204 -0
  29. package/package.json +43 -0
  30. package/src/cli.ts +54 -0
  31. package/src/commands/build.ts +72 -0
  32. package/src/commands/init.ts +112 -0
  33. package/src/commands/verify.ts +114 -0
  34. package/src/core/generateRobots.ts +18 -0
  35. package/src/core/generateSitemap.ts +38 -0
  36. package/src/core/generateWorker.ts +80 -0
  37. package/src/core/loadConfig.ts +74 -0
  38. package/src/core/loadMarkdown.ts +82 -0
  39. package/src/core/renderHtml.ts +76 -0
  40. package/src/core/validateOutput.ts +148 -0
  41. package/src/types/markdown-it.d.ts +20 -0
  42. 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("&", "&amp;")
12
+ .replaceAll("<", "&lt;")
13
+ .replaceAll(">", "&gt;")
14
+ .replaceAll('"', "&quot;")
15
+ .replaceAll("'", "&apos;");
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
+ }