@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,80 @@
|
|
|
1
|
+
import { RenderShieldConfig } from "../types.js";
|
|
2
|
+
|
|
3
|
+
function jsStringArray(arr: string[]): string {
|
|
4
|
+
return `[${arr.map((s) => JSON.stringify(s)).join(", ")}]`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function generateWorkerJs(cfg: RenderShieldConfig): string {
|
|
8
|
+
const patterns = cfg.worker.botUserAgentPatterns.map((p) => p.toLowerCase());
|
|
9
|
+
const rewriteBases = cfg.worker.rewriteRouteBases;
|
|
10
|
+
|
|
11
|
+
return `/**
|
|
12
|
+
* RenderShield Worker (generated)
|
|
13
|
+
* Serves prerendered /index.html to bots on selected route bases.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BOT_SUBSTRINGS = ${jsStringArray(patterns)};
|
|
17
|
+
const REWRITE_BASES = ${jsStringArray(rewriteBases)};
|
|
18
|
+
|
|
19
|
+
function isBot(ua) {
|
|
20
|
+
const s = (ua || "").toLowerCase();
|
|
21
|
+
return BOT_SUBSTRINGS.some((sub) => s.includes(sub));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shouldRewrite(pathname) {
|
|
25
|
+
return REWRITE_BASES.some((base) => pathname.startsWith(base));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toIndexHtml(pathname) {
|
|
29
|
+
let p = pathname;
|
|
30
|
+
if (!p.endsWith("/")) p += "/";
|
|
31
|
+
return p + "index.html";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
async fetch(request, env, ctx) {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
const ua = request.headers.get("User-Agent") || "";
|
|
38
|
+
const bot = isBot(ua);
|
|
39
|
+
|
|
40
|
+
const isGetLike = request.method === "GET" || request.method === "HEAD";
|
|
41
|
+
const rewrite = bot && isGetLike && shouldRewrite(url.pathname);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (!rewrite) return fetch(request);
|
|
45
|
+
|
|
46
|
+
const origin = ${JSON.stringify(cfg.worker.lovableOrigin)};
|
|
47
|
+
const finalPath = toIndexHtml(url.pathname);
|
|
48
|
+
const originUrl = origin + finalPath + url.search;
|
|
49
|
+
|
|
50
|
+
const headers = new Headers();
|
|
51
|
+
headers.set("Accept", "text/html");
|
|
52
|
+
headers.set("User-Agent", ua);
|
|
53
|
+
|
|
54
|
+
const resp = await fetch(originUrl, { method: "GET", headers });
|
|
55
|
+
|
|
56
|
+
if (!resp.ok) {
|
|
57
|
+
const fallbackUrl = origin + url.pathname + url.search;
|
|
58
|
+
const fb = await fetch(fallbackUrl, { method: "GET", headers });
|
|
59
|
+
const out = new Response(fb.body, fb);
|
|
60
|
+
${cfg.worker.debugHeaders ? `out.headers.set("X-Bot-Detected", "true");
|
|
61
|
+
out.headers.set("X-Prerender-Fallback", "true");
|
|
62
|
+
out.headers.set("X-Requested-Path", url.pathname);` : ""}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const out = new Response(resp.body, resp);
|
|
67
|
+
${cfg.worker.debugHeaders ? `out.headers.set("X-Bot-Detected", "true");
|
|
68
|
+
out.headers.set("X-Prerender", "true");
|
|
69
|
+
out.headers.set("X-Final-Path", finalPath);` : ""}
|
|
70
|
+
return out;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return new Response("Worker error: " + (err && err.message ? err.message : String(err)), {
|
|
73
|
+
status: 500,
|
|
74
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RenderShieldConfig } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const CONFIG_NAME = "rendershield.config.json";
|
|
6
|
+
|
|
7
|
+
type BoolFlag = { enabled: boolean };
|
|
8
|
+
|
|
9
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
10
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function coerceBoolFlag(
|
|
14
|
+
parsed: any,
|
|
15
|
+
key: "sitemap" | "robots" | "worker",
|
|
16
|
+
defaultEnabled: boolean
|
|
17
|
+
): BoolFlag {
|
|
18
|
+
// If missing, provide defaults (keeps older configs from exploding)
|
|
19
|
+
if (parsed?.[key] == null) return { enabled: defaultEnabled };
|
|
20
|
+
|
|
21
|
+
// If present, validate shape
|
|
22
|
+
const v = parsed[key];
|
|
23
|
+
if (!isObject(v)) {
|
|
24
|
+
throw new Error(`${key} must be an object like { "enabled": true }`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof (v as any).enabled !== "boolean") {
|
|
27
|
+
throw new Error(`${key}.enabled must be a boolean`);
|
|
28
|
+
}
|
|
29
|
+
return { enabled: (v as any).enabled };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadConfig(
|
|
33
|
+
cwd = process.cwd()
|
|
34
|
+
): Promise<RenderShieldConfig> {
|
|
35
|
+
const p = path.join(cwd, CONFIG_NAME);
|
|
36
|
+
const exists = await fs.pathExists(p);
|
|
37
|
+
if (!exists) {
|
|
38
|
+
throw new Error(`Missing ${CONFIG_NAME}. Run: rendershield init`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const raw = await fs.readFile(p, "utf8");
|
|
42
|
+
let parsed: any;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`${CONFIG_NAME} is not valid JSON`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Minimal validation (v0)
|
|
50
|
+
if (parsed?.version !== 1) throw new Error(`Config version must be 1`);
|
|
51
|
+
if (!parsed?.site?.canonicalBase)
|
|
52
|
+
throw new Error(`site.canonicalBase is required`);
|
|
53
|
+
if (!parsed?.site?.siteName) throw new Error(`site.siteName is required`);
|
|
54
|
+
if (!parsed?.site?.defaultOgImage)
|
|
55
|
+
throw new Error(`site.defaultOgImage is required`);
|
|
56
|
+
if (!parsed?.site?.authorName)
|
|
57
|
+
throw new Error(`site.authorName is required`);
|
|
58
|
+
if (!parsed?.content?.markdown?.baseDir)
|
|
59
|
+
throw new Error(`content.markdown.baseDir is required`);
|
|
60
|
+
if (
|
|
61
|
+
!Array.isArray(parsed?.content?.markdown?.collections) ||
|
|
62
|
+
parsed.content.markdown.collections.length === 0
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(`content.markdown.collections must be a non-empty array`);
|
|
65
|
+
}
|
|
66
|
+
if (!parsed?.output?.outDir) throw new Error(`output.outDir is required`);
|
|
67
|
+
|
|
68
|
+
// Validate + default these optional sections to prevent TypeErrors later
|
|
69
|
+
parsed.sitemap = coerceBoolFlag(parsed, "sitemap", true);
|
|
70
|
+
parsed.robots = coerceBoolFlag(parsed, "robots", true);
|
|
71
|
+
parsed.worker = coerceBoolFlag(parsed, "worker", true);
|
|
72
|
+
|
|
73
|
+
return parsed as RenderShieldConfig;
|
|
74
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import MarkdownIt from "markdown-it";
|
|
6
|
+
import { MarkdownDoc, RenderShieldConfig } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
|
|
9
|
+
|
|
10
|
+
const REQUIRED_FIELDS = "title, excerpt, datePublished, coverImage, slug";
|
|
11
|
+
|
|
12
|
+
function requireString(value: any, field: string, file: string): string {
|
|
13
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Missing required frontmatter field "${field}" in ${file}. Required fields: ${REQUIRED_FIELDS}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return value.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeDate(value: any, file: string): string {
|
|
22
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
if (value instanceof Date) {
|
|
26
|
+
return value.toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Invalid datePublished in ${file}. Use format YYYY-MM-DD.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadAllMarkdownDocs(cfg: RenderShieldConfig, cwd = process.cwd()): Promise<MarkdownDoc[]> {
|
|
32
|
+
const baseDirAbs = path.join(cwd, cfg.content.markdown.baseDir);
|
|
33
|
+
|
|
34
|
+
const out: MarkdownDoc[] = [];
|
|
35
|
+
|
|
36
|
+
for (const col of cfg.content.markdown.collections) {
|
|
37
|
+
const pattern = col.pattern;
|
|
38
|
+
const matches = await fg(pattern, { cwd: baseDirAbs, onlyFiles: true });
|
|
39
|
+
|
|
40
|
+
for (const rel of matches) {
|
|
41
|
+
const abs = path.join(baseDirAbs, rel);
|
|
42
|
+
const raw = await fs.readFile(abs, "utf8");
|
|
43
|
+
const parsed = matter(raw);
|
|
44
|
+
|
|
45
|
+
const title = requireString(parsed.data?.title, "title", abs);
|
|
46
|
+
const excerpt = requireString(parsed.data?.excerpt, "excerpt", abs);
|
|
47
|
+
|
|
48
|
+
const datePublishedRaw = parsed.data?.datePublished;
|
|
49
|
+
if (datePublishedRaw === undefined || datePublishedRaw === null) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Missing required frontmatter field "datePublished" in ${abs}. Required fields: ${REQUIRED_FIELDS}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const datePublished = normalizeDate(datePublishedRaw, abs);
|
|
55
|
+
|
|
56
|
+
const coverImage = requireString(parsed.data?.coverImage, "coverImage", abs);
|
|
57
|
+
const slug = requireString(parsed.data?.slug, "slug", abs);
|
|
58
|
+
|
|
59
|
+
// Route: /blog/<slug>
|
|
60
|
+
const routeBase = col.routeBase.endsWith("/") ? col.routeBase.slice(0, -1) : col.routeBase;
|
|
61
|
+
const routePath = `${routeBase}/${slug}`;
|
|
62
|
+
|
|
63
|
+
const htmlContent = md.render(parsed.content ?? "");
|
|
64
|
+
|
|
65
|
+
out.push({
|
|
66
|
+
sourcePath: abs,
|
|
67
|
+
collection: col.name,
|
|
68
|
+
routePath,
|
|
69
|
+
title,
|
|
70
|
+
excerpt,
|
|
71
|
+
datePublished,
|
|
72
|
+
coverImage,
|
|
73
|
+
slug,
|
|
74
|
+
htmlContent,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Deterministic order
|
|
80
|
+
out.sort((a, b) => a.routePath.localeCompare(b.routePath));
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { MarkdownDoc, RenderShieldConfig } from "../types.js";
|
|
2
|
+
|
|
3
|
+
function escapeHtml(s: string): string {
|
|
4
|
+
return s
|
|
5
|
+
.replaceAll("&", "&")
|
|
6
|
+
.replaceAll("<", "<")
|
|
7
|
+
.replaceAll(">", ">")
|
|
8
|
+
.replaceAll('"', """)
|
|
9
|
+
.replaceAll("'", "'");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function joinUrl(base: string, pathname: string): string {
|
|
13
|
+
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
14
|
+
const p = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
15
|
+
return b + p;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderPageHtml(cfg: RenderShieldConfig, doc: MarkdownDoc): string {
|
|
19
|
+
const canonicalUrl = joinUrl(cfg.site.canonicalBase, doc.routePath);
|
|
20
|
+
const ogImageUrl = doc.coverImage.startsWith("http")
|
|
21
|
+
? doc.coverImage
|
|
22
|
+
: joinUrl(cfg.site.canonicalBase, doc.coverImage);
|
|
23
|
+
|
|
24
|
+
const title = `${doc.title} - ${cfg.site.siteName}`;
|
|
25
|
+
const description = doc.excerpt;
|
|
26
|
+
|
|
27
|
+
const jsonLd = {
|
|
28
|
+
"@context": "https://schema.org",
|
|
29
|
+
"@type": "Article",
|
|
30
|
+
headline: doc.title,
|
|
31
|
+
author: { "@type": "Person", name: cfg.site.authorName },
|
|
32
|
+
datePublished: doc.datePublished,
|
|
33
|
+
image: ogImageUrl,
|
|
34
|
+
mainEntityOfPage: canonicalUrl,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// NOTE: doc.htmlContent is already HTML from markdown-it.
|
|
38
|
+
// We trust it as generated output, not user-injected raw HTML (markdown-it html:false).
|
|
39
|
+
const html = `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
|
|
45
|
+
<title>${escapeHtml(title)}</title>
|
|
46
|
+
<meta name="description" content="${escapeHtml(description)}">
|
|
47
|
+
<link rel="canonical" href="${escapeHtml(canonicalUrl)}">
|
|
48
|
+
|
|
49
|
+
<meta property="og:type" content="article">
|
|
50
|
+
<meta property="og:title" content="${escapeHtml(doc.title)}">
|
|
51
|
+
<meta property="og:description" content="${escapeHtml(description)}">
|
|
52
|
+
<meta property="og:image" content="${escapeHtml(ogImageUrl)}">
|
|
53
|
+
<meta property="og:url" content="${escapeHtml(canonicalUrl)}">
|
|
54
|
+
|
|
55
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
56
|
+
<meta name="twitter:title" content="${escapeHtml(doc.title)}">
|
|
57
|
+
<meta name="twitter:description" content="${escapeHtml(description)}">
|
|
58
|
+
<meta name="twitter:image" content="${escapeHtml(ogImageUrl)}">
|
|
59
|
+
|
|
60
|
+
<script type="application/ld+json">${escapeHtml(JSON.stringify(jsonLd))}</script>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<main>
|
|
64
|
+
<article>
|
|
65
|
+
<header>
|
|
66
|
+
<h1>${escapeHtml(doc.title)}</h1>
|
|
67
|
+
<p><time datetime="${escapeHtml(doc.datePublished)}">${escapeHtml(doc.datePublished)}</time></p>
|
|
68
|
+
</header>
|
|
69
|
+
${doc.htmlContent}
|
|
70
|
+
</article>
|
|
71
|
+
</main>
|
|
72
|
+
</body>
|
|
73
|
+
</html>`;
|
|
74
|
+
|
|
75
|
+
return cfg.output.prettyHtml ? html : html.replace(/\s+/g, " ");
|
|
76
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
type ValidateParams = {
|
|
2
|
+
html: string;
|
|
3
|
+
outFile: string;
|
|
4
|
+
routePath: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function hasNonEmptyTitle(html: string): boolean {
|
|
8
|
+
const m = html.match(/<title>([\s\S]*?)<\/title>/i);
|
|
9
|
+
if (!m) return false;
|
|
10
|
+
const text = (m[1] ?? "").trim();
|
|
11
|
+
return text.length > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getMetaContent(html: string, name: string): string | null {
|
|
15
|
+
// matches: <meta name="description" content="...">
|
|
16
|
+
const re = new RegExp(
|
|
17
|
+
`<meta\\s+[^>]*name=["']${escapeRegExp(name)}["'][^>]*>`,
|
|
18
|
+
"i"
|
|
19
|
+
);
|
|
20
|
+
const tag = html.match(re)?.[0];
|
|
21
|
+
if (!tag) return null;
|
|
22
|
+
|
|
23
|
+
const contentMatch = tag.match(/content=["']([^"']+)["']/i);
|
|
24
|
+
return contentMatch?.[1]?.trim() ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getLinkHref(html: string, rel: string): string | null {
|
|
28
|
+
const re = new RegExp(
|
|
29
|
+
`<link\\s+[^>]*rel=["']${escapeRegExp(rel)}["'][^>]*>`,
|
|
30
|
+
"i"
|
|
31
|
+
);
|
|
32
|
+
const tag = html.match(re)?.[0];
|
|
33
|
+
if (!tag) return null;
|
|
34
|
+
|
|
35
|
+
const hrefMatch = tag.match(/href=["']([^"']+)["']/i);
|
|
36
|
+
return hrefMatch?.[1]?.trim() ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getOgContent(html: string, property: string): string | null {
|
|
40
|
+
const re = new RegExp(
|
|
41
|
+
`<meta\\s+[^>]*property=["']${escapeRegExp(property)}["'][^>]*>`,
|
|
42
|
+
"i"
|
|
43
|
+
);
|
|
44
|
+
const tag = html.match(re)?.[0];
|
|
45
|
+
if (!tag) return null;
|
|
46
|
+
|
|
47
|
+
const contentMatch = tag.match(/content=["']([^"']+)["']/i);
|
|
48
|
+
return contentMatch?.[1]?.trim() ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getJsonLd(html: string): string | null {
|
|
52
|
+
const m = html.match(
|
|
53
|
+
/<script\s+[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/i
|
|
54
|
+
);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
return (m[1] ?? "").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getArticleInnerHtml(html: string): string | null {
|
|
60
|
+
const m = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return (m[1] ?? "").trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stripTags(s: string): string {
|
|
66
|
+
return s
|
|
67
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
68
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
69
|
+
.replace(/<\/?[^>]+>/g, " ")
|
|
70
|
+
.replace(/\s+/g, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wordCount(s: string): number {
|
|
75
|
+
if (!s.trim()) return 0;
|
|
76
|
+
return s.trim().split(/\s+/).filter(Boolean).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function escapeRegExp(s: string): string {
|
|
80
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function validatePrerenderHtml(params: ValidateParams): void {
|
|
84
|
+
const { html, outFile, routePath } = params;
|
|
85
|
+
|
|
86
|
+
const missing: string[] = [];
|
|
87
|
+
|
|
88
|
+
// 1) Title
|
|
89
|
+
if (!hasNonEmptyTitle(html)) missing.push("Missing or empty <title>");
|
|
90
|
+
|
|
91
|
+
// 2) Meta description
|
|
92
|
+
const desc = getMetaContent(html, "description");
|
|
93
|
+
if (!desc) missing.push('Missing <meta name="description" content="...">');
|
|
94
|
+
|
|
95
|
+
// 3) Canonical
|
|
96
|
+
const canonical = getLinkHref(html, "canonical");
|
|
97
|
+
if (!canonical) missing.push('Missing <link rel="canonical" href="...">');
|
|
98
|
+
|
|
99
|
+
// 4) Open Graph tags
|
|
100
|
+
const ogTitle = getOgContent(html, "og:title");
|
|
101
|
+
const ogDesc = getOgContent(html, "og:description");
|
|
102
|
+
const ogImg = getOgContent(html, "og:image");
|
|
103
|
+
const ogUrl = getOgContent(html, "og:url");
|
|
104
|
+
|
|
105
|
+
if (!ogTitle) missing.push("Missing Open Graph tag: og:title");
|
|
106
|
+
if (!ogDesc) missing.push("Missing Open Graph tag: og:description");
|
|
107
|
+
if (!ogImg) missing.push("Missing Open Graph tag: og:image");
|
|
108
|
+
if (!ogUrl) missing.push("Missing Open Graph tag: og:url");
|
|
109
|
+
|
|
110
|
+
// 5) JSON-LD
|
|
111
|
+
const jsonLd = getJsonLd(html);
|
|
112
|
+
if (!jsonLd) {
|
|
113
|
+
missing.push('Missing JSON-LD: <script type="application/ld+json">...</script>');
|
|
114
|
+
} else if (jsonLd.length <= 20) {
|
|
115
|
+
missing.push("JSON-LD script present but too short/empty");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 6) Article content
|
|
119
|
+
const articleInner = getArticleInnerHtml(html);
|
|
120
|
+
if (!articleInner) {
|
|
121
|
+
missing.push("Missing <article>...</article>");
|
|
122
|
+
} else {
|
|
123
|
+
const text = stripTags(articleInner);
|
|
124
|
+
const words = wordCount(text);
|
|
125
|
+
|
|
126
|
+
// Require either enough characters or enough words
|
|
127
|
+
const okByChars = text.length >= 80;
|
|
128
|
+
const okByWords = words >= 20;
|
|
129
|
+
|
|
130
|
+
if (!okByChars && !okByWords) {
|
|
131
|
+
missing.push(
|
|
132
|
+
`Article content too short (got ${words} words, ${text.length} chars). Require >= 20 words or >= 80 chars.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (missing.length > 0) {
|
|
138
|
+
const msg =
|
|
139
|
+
`RenderShield validation failed for prerendered page:\n` +
|
|
140
|
+
`- routePath: ${routePath}\n` +
|
|
141
|
+
`- outFile: ${outFile}\n` +
|
|
142
|
+
`Missing/invalid requirements:\n` +
|
|
143
|
+
missing.map((m) => `- ${m}`).join("\n") +
|
|
144
|
+
`\n\nFix the source content or renderer so bots receive complete HTML.`;
|
|
145
|
+
|
|
146
|
+
throw new Error(msg);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
declare module "markdown-it" {
|
|
2
|
+
type MarkdownItOptions = {
|
|
3
|
+
html?: boolean;
|
|
4
|
+
xhtmlOut?: boolean;
|
|
5
|
+
breaks?: boolean;
|
|
6
|
+
langPrefix?: string;
|
|
7
|
+
linkify?: boolean;
|
|
8
|
+
typographer?: boolean;
|
|
9
|
+
quotes?: string | string[];
|
|
10
|
+
highlight?: (str: string, lang: string) => string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class MarkdownIt {
|
|
14
|
+
constructor(options?: MarkdownItOptions);
|
|
15
|
+
render(markdown: string): string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default MarkdownIt;
|
|
19
|
+
}
|
|
20
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type RenderShieldConfig = {
|
|
2
|
+
version: 1;
|
|
3
|
+
site: {
|
|
4
|
+
canonicalBase: string; // https://example.com
|
|
5
|
+
siteName: string;
|
|
6
|
+
defaultOgImage: string;
|
|
7
|
+
authorName: string;
|
|
8
|
+
};
|
|
9
|
+
content: {
|
|
10
|
+
markdown: {
|
|
11
|
+
baseDir: string; // content
|
|
12
|
+
collections: Array<{
|
|
13
|
+
name: string; // blog
|
|
14
|
+
pattern: string; // blog/**/*.md
|
|
15
|
+
routeBase: string; // /blog
|
|
16
|
+
schemaType: "Article"; // v0 only supports Article
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
output: {
|
|
21
|
+
outDir: string; // dist-prerender
|
|
22
|
+
prettyHtml: boolean;
|
|
23
|
+
};
|
|
24
|
+
sitemap: {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
path: string; // /sitemap.xml
|
|
27
|
+
};
|
|
28
|
+
robots: {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
path: string; // /robots.txt
|
|
31
|
+
};
|
|
32
|
+
worker: {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
lovableOrigin: string; // https://YOUR_SITE.lovable.app
|
|
35
|
+
rewriteRouteBases: string[]; // ["/blog/"]
|
|
36
|
+
botUserAgentPatterns: string[];
|
|
37
|
+
debugHeaders: boolean;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type MarkdownDoc = {
|
|
42
|
+
sourcePath: string;
|
|
43
|
+
collection: string;
|
|
44
|
+
routePath: string; // /blog/slug
|
|
45
|
+
title: string;
|
|
46
|
+
excerpt: string;
|
|
47
|
+
datePublished: string; // YYYY-MM-DD
|
|
48
|
+
coverImage: string; // /images/...
|
|
49
|
+
slug: string;
|
|
50
|
+
htmlContent: string; // rendered <p>...
|
|
51
|
+
};
|
|
52
|
+
|