Initial commit
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
import { copyFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import { ensureDir, fileExists, writeText } from "../util/fsx.js";
|
||||
import type { CaptureResult, SeoResource } from "../capture/capture.js";
|
||||
import type { AssetEntry, AssetGraph } from "../infer/assets.js";
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
|
||||
export type SeoInventory = {
|
||||
sourceUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
canonicalUrl: string;
|
||||
robots: string;
|
||||
referrer: string;
|
||||
themeColor: string;
|
||||
colorScheme: string;
|
||||
openGraph: Array<{ property: string; content: string }>;
|
||||
twitter: Array<{ name: string; content: string }>;
|
||||
icons: Array<{
|
||||
rel: string;
|
||||
href: string;
|
||||
localPath: string | null;
|
||||
type?: string;
|
||||
sizes?: string;
|
||||
media?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
manifest: {
|
||||
href: string;
|
||||
localPath: string | null;
|
||||
assets: Array<{ sourceUrl: string; localPath: string | null; type: string }>;
|
||||
} | null;
|
||||
alternates: Array<{ hrefLang: string; href: string }>;
|
||||
jsonLd: Array<{ id?: string; text: string; types: string[] }>;
|
||||
resources: SeoResource[];
|
||||
metrics: {
|
||||
metaFields: number;
|
||||
openGraphTags: number;
|
||||
twitterTags: number;
|
||||
iconLinks: number;
|
||||
manifestAssets: number;
|
||||
alternates: number;
|
||||
jsonLdBlocks: number;
|
||||
llmsTxt: boolean;
|
||||
llmsFullTxt: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SeoRouteSummary = {
|
||||
routePath: string;
|
||||
href: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
type HeadMeta = { name?: string; property?: string; httpEquiv?: string; content: string };
|
||||
|
||||
const clean = (value: string | undefined | null): string => (value ?? "").replace(/\s+/g, " ").trim();
|
||||
const lower = (value: string | undefined): string => (value ?? "").toLowerCase();
|
||||
const relTokens = (rel: string): Set<string> => new Set(rel.toLowerCase().split(/\s+/).filter(Boolean));
|
||||
|
||||
function metaContent(meta: HeadMeta[], key: string, kind: "name" | "property" | "httpEquiv" = "name"): string {
|
||||
const wanted = key.toLowerCase();
|
||||
for (const m of meta) {
|
||||
const actual = kind === "name" ? m.name : kind === "property" ? m.property : m.httpEquiv;
|
||||
if (actual && actual.toLowerCase() === wanted && m.content) return m.content.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function metaPrefix(meta: HeadMeta[], prefix: string, kind: "name" | "property"): Array<{ key: string; content: string }> {
|
||||
const p = prefix.toLowerCase();
|
||||
const out: Array<{ key: string; content: string }> = [];
|
||||
for (const m of meta) {
|
||||
const key = kind === "name" ? m.name : m.property;
|
||||
const content = clean(m.content);
|
||||
if (key && key.toLowerCase().startsWith(p) && content) out.push({ key, content });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assetFor(assetGraph: AssetGraph, url: string): AssetEntry | undefined {
|
||||
return assetGraph.byUrl.get(url);
|
||||
}
|
||||
|
||||
function jsonLdTypes(text: string): string[] {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { return []; }
|
||||
const types = new Set<string>();
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const t = obj["@type"];
|
||||
if (typeof t === "string" && t.trim()) types.add(t.trim());
|
||||
else if (Array.isArray(t)) for (const item of t) if (typeof item === "string" && item.trim()) types.add(item.trim());
|
||||
if (Array.isArray(obj["@graph"])) visit(obj["@graph"]);
|
||||
};
|
||||
visit(parsed);
|
||||
return [...types].sort();
|
||||
}
|
||||
|
||||
function collectText(node: IRNode, out: string[]): void {
|
||||
for (const child of node.children) {
|
||||
if (isTextChild(child)) {
|
||||
const text = clean(child.text);
|
||||
if (text) out.push(text);
|
||||
} else {
|
||||
collectText(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function routeSummaryFromIr(ir: IR, routePath: string, href: string, url: string): SeoRouteSummary {
|
||||
const parts: string[] = [];
|
||||
collectText(ir.root, parts);
|
||||
const excerpt = parts.join(" ").replace(/\s+/g, " ").slice(0, 700).trim();
|
||||
return {
|
||||
routePath,
|
||||
href,
|
||||
url,
|
||||
title: ir.doc.title || (routePath === "/" ? "Home" : routePath),
|
||||
description: ir.doc.head?.description || "",
|
||||
excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeoInventory(ir: IR, assetGraph: AssetGraph, capture?: CaptureResult): SeoInventory {
|
||||
const head = ir.doc.head ?? {};
|
||||
const meta = head.meta ?? [];
|
||||
const links = head.links ?? [];
|
||||
const title = ir.doc.title || "";
|
||||
const description = metaContent(meta, "description") || head.description || "";
|
||||
const keywords = metaContent(meta, "keywords") || head.keywords || "";
|
||||
const canonicalUrl = links.find((l) => relTokens(l.rel).has("canonical"))?.href || head.canonical || "";
|
||||
const robots = metaContent(meta, "robots") || head.robots || "";
|
||||
const referrer = metaContent(meta, "referrer") || head.referrer || "";
|
||||
const themeColor = metaContent(meta, "theme-color") || head.themeColor || "";
|
||||
const colorScheme = metaContent(meta, "color-scheme") || head.colorScheme || "";
|
||||
|
||||
const openGraph = metaPrefix(meta, "og:", "property");
|
||||
if (!openGraph.length) {
|
||||
if (head.ogTitle) openGraph.push({ key: "og:title", content: head.ogTitle });
|
||||
if (head.ogDescription) openGraph.push({ key: "og:description", content: head.ogDescription });
|
||||
if (head.ogImage) openGraph.push({ key: "og:image", content: head.ogImage });
|
||||
if (head.ogType) openGraph.push({ key: "og:type", content: head.ogType });
|
||||
if (head.ogSiteName) openGraph.push({ key: "og:site_name", content: head.ogSiteName });
|
||||
}
|
||||
const twitter = metaPrefix(meta, "twitter:", "name");
|
||||
if (!twitter.length && head.twitterCard) twitter.push({ key: "twitter:card", content: head.twitterCard });
|
||||
|
||||
const icons = links.filter((link) => {
|
||||
const rel = link.rel.toLowerCase();
|
||||
return /\b(?:icon|shortcut|apple-touch-icon|mask-icon)\b/.test(rel);
|
||||
}).map((link) => ({
|
||||
rel: link.rel,
|
||||
href: link.href,
|
||||
localPath: assetFor(assetGraph, link.href)?.localPath ?? null,
|
||||
...(link.type ? { type: link.type } : {}),
|
||||
...(link.sizes ? { sizes: link.sizes } : {}),
|
||||
...(link.media ? { media: link.media } : {}),
|
||||
...(link.color ? { color: link.color } : {}),
|
||||
}));
|
||||
|
||||
const manifestLink = links.find((link) => relTokens(link.rel).has("manifest"));
|
||||
const manifestAssets = assetGraph.entries
|
||||
.filter((entry) => entry.via.some((via) => via.startsWith("manifest:")))
|
||||
.map((entry) => ({ sourceUrl: entry.sourceUrl, localPath: entry.localPath, type: entry.type }));
|
||||
const manifest = manifestLink ? {
|
||||
href: manifestLink.href,
|
||||
localPath: assetFor(assetGraph, manifestLink.href)?.localPath ?? null,
|
||||
assets: manifestAssets,
|
||||
} : null;
|
||||
|
||||
const alternates = links.filter((link) => relTokens(link.rel).has("alternate") && link.hrefLang && link.href)
|
||||
.map((link) => ({ hrefLang: link.hrefLang!, href: link.href }));
|
||||
const jsonLd = (head.jsonLd ?? []).map((entry) => ({ ...entry, types: jsonLdTypes(entry.text) }));
|
||||
const resources = [...(capture?.seoResources ?? [])].sort((a, b) => a.url.localeCompare(b.url) || a.kind.localeCompare(b.kind));
|
||||
|
||||
const metaFields = [title, description, keywords, canonicalUrl, robots, referrer, themeColor, colorScheme].filter(Boolean).length;
|
||||
return {
|
||||
sourceUrl: ir.doc.sourceUrl,
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
canonicalUrl,
|
||||
robots,
|
||||
referrer,
|
||||
themeColor,
|
||||
colorScheme,
|
||||
openGraph: openGraph.map((m) => ({ property: m.key, content: m.content })),
|
||||
twitter: twitter.map((m) => ({ name: m.key, content: m.content })),
|
||||
icons,
|
||||
manifest,
|
||||
alternates,
|
||||
jsonLd,
|
||||
resources,
|
||||
metrics: {
|
||||
metaFields,
|
||||
openGraphTags: openGraph.length,
|
||||
twitterTags: twitter.length,
|
||||
iconLinks: icons.length,
|
||||
manifestAssets: manifestAssets.length,
|
||||
alternates: alternates.length,
|
||||
jsonLdBlocks: jsonLd.length,
|
||||
llmsTxt: resources.some((r) => r.kind === "llms" && r.status === 200 && !!r.text),
|
||||
llmsFullTxt: resources.some((r) => r.kind === "llms-full" && r.status === 200 && !!r.text),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function seoInventoryToMarkdown(report: SeoInventory): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# SEO Inventory", "");
|
||||
lines.push(`- source: ${report.sourceUrl}`);
|
||||
lines.push(`- title: ${report.title || "(missing)"}`);
|
||||
lines.push(`- description: ${report.description || "(missing)"}`);
|
||||
lines.push(`- keywords: ${report.keywords || "(missing)"}`);
|
||||
lines.push(`- canonical: ${report.canonicalUrl || "(missing)"}`);
|
||||
lines.push(`- robots: ${report.robots || "(missing)"}`);
|
||||
lines.push(`- referrer: ${report.referrer || "(missing)"}`);
|
||||
lines.push(`- theme-color: ${report.themeColor || "(missing)"}`);
|
||||
lines.push(`- color-scheme: ${report.colorScheme || "(missing)"}`);
|
||||
lines.push("");
|
||||
lines.push("## Coverage", "");
|
||||
lines.push(`- Open Graph tags: ${report.metrics.openGraphTags}`);
|
||||
lines.push(`- Twitter card tags: ${report.metrics.twitterTags}`);
|
||||
lines.push(`- icon links: ${report.metrics.iconLinks}`);
|
||||
lines.push(`- manifest assets: ${report.metrics.manifestAssets}`);
|
||||
lines.push(`- alternates/hreflang: ${report.metrics.alternates}`);
|
||||
lines.push(`- JSON-LD blocks: ${report.metrics.jsonLdBlocks}`);
|
||||
lines.push(`- source llms.txt: ${report.metrics.llmsTxt ? "yes" : "no"}`);
|
||||
lines.push(`- source llms-full.txt: ${report.metrics.llmsFullTxt ? "yes" : "no"}`);
|
||||
if (report.icons.length) {
|
||||
lines.push("", "## Icons", "");
|
||||
for (const icon of report.icons) lines.push(`- ${icon.rel}: ${icon.localPath || icon.href}${icon.sizes ? ` (${icon.sizes})` : ""}`);
|
||||
}
|
||||
if (report.manifest) {
|
||||
lines.push("", "## Manifest", "");
|
||||
lines.push(`- link: ${report.manifest.localPath || report.manifest.href}`);
|
||||
for (const asset of report.manifest.assets) lines.push(`- asset: ${asset.localPath || asset.sourceUrl}`);
|
||||
}
|
||||
if (report.jsonLd.length) {
|
||||
lines.push("", "## Structured Data", "");
|
||||
for (const block of report.jsonLd) lines.push(`- ${block.types.length ? block.types.join(", ") : "JSON-LD"}${block.id ? ` (#${block.id})` : ""}`);
|
||||
}
|
||||
if (report.resources.length) {
|
||||
lines.push("", "## Discovered Files", "");
|
||||
for (const resource of report.resources) lines.push(`- ${resource.kind}: ${resource.status ?? "unreachable"} ${resource.url}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function firstValue(entries: Array<{ property?: string; name?: string; content: string }>, key: string): string {
|
||||
const wanted = key.toLowerCase();
|
||||
const found = entries.find((entry) => (entry.property ?? entry.name ?? "").toLowerCase() === wanted);
|
||||
return found?.content ?? "";
|
||||
}
|
||||
|
||||
function keywordList(keywords: string): string[] | undefined {
|
||||
const parts = keywords.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length ? parts : undefined;
|
||||
}
|
||||
|
||||
function iconUrl(icon: SeoInventory["icons"][number]): string {
|
||||
return icon.localPath || icon.href;
|
||||
}
|
||||
|
||||
function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = { title: report.title || "Cloned Page" };
|
||||
if (report.description) metadata.description = report.description;
|
||||
const keywords = keywordList(report.keywords);
|
||||
if (keywords) metadata.keywords = keywords;
|
||||
if (report.robots) metadata.robots = report.robots;
|
||||
if (report.referrer) metadata.referrer = report.referrer;
|
||||
const alternates: Record<string, unknown> = {};
|
||||
if (report.canonicalUrl) alternates.canonical = report.canonicalUrl;
|
||||
if (report.alternates.length) {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const alt of report.alternates) languages[alt.hrefLang] = alt.href;
|
||||
alternates.languages = languages;
|
||||
}
|
||||
if (Object.keys(alternates).length) metadata.alternates = alternates;
|
||||
|
||||
const ogEntries = report.openGraph.map((entry) => ({ property: entry.property, content: entry.content }));
|
||||
const og: Record<string, unknown> = {};
|
||||
const ogTitle = firstValue(ogEntries, "og:title");
|
||||
const ogDescription = firstValue(ogEntries, "og:description");
|
||||
const ogType = firstValue(ogEntries, "og:type");
|
||||
const ogSiteName = firstValue(ogEntries, "og:site_name");
|
||||
const ogUrl = firstValue(ogEntries, "og:url");
|
||||
const ogImages = ogEntries.filter((entry) => entry.property?.toLowerCase() === "og:image").map((entry) => entry.content);
|
||||
if (ogTitle) og.title = ogTitle;
|
||||
if (ogDescription) og.description = ogDescription;
|
||||
if (ogType) og.type = ogType;
|
||||
if (ogSiteName) og.siteName = ogSiteName;
|
||||
if (ogUrl) og.url = ogUrl;
|
||||
if (ogImages.length) og.images = ogImages;
|
||||
if (Object.keys(og).length) metadata.openGraph = og;
|
||||
|
||||
const twEntries = report.twitter.map((entry) => ({ name: entry.name, content: entry.content }));
|
||||
const twitter: Record<string, unknown> = {};
|
||||
const twCard = firstValue(twEntries, "twitter:card");
|
||||
const twTitle = firstValue(twEntries, "twitter:title");
|
||||
const twDescription = firstValue(twEntries, "twitter:description");
|
||||
const twSite = firstValue(twEntries, "twitter:site");
|
||||
const twCreator = firstValue(twEntries, "twitter:creator");
|
||||
const twImages = twEntries.filter((entry) => entry.name?.toLowerCase() === "twitter:image").map((entry) => entry.content);
|
||||
if (twCard) twitter.card = twCard;
|
||||
if (twTitle) twitter.title = twTitle;
|
||||
if (twDescription) twitter.description = twDescription;
|
||||
if (twSite) twitter.site = twSite;
|
||||
if (twCreator) twitter.creator = twCreator;
|
||||
if (twImages.length) twitter.images = twImages;
|
||||
if (Object.keys(twitter).length) metadata.twitter = twitter;
|
||||
|
||||
const icons: Record<string, unknown[]> = {};
|
||||
for (const icon of report.icons) {
|
||||
const rel = icon.rel.toLowerCase();
|
||||
const item: Record<string, unknown> = { url: iconUrl(icon) };
|
||||
if (icon.type) item.type = icon.type;
|
||||
if (icon.sizes) item.sizes = icon.sizes;
|
||||
if (icon.media) item.media = icon.media;
|
||||
if (rel.includes("apple-touch-icon")) (icons.apple ??= []).push(item);
|
||||
else if (rel.includes("shortcut")) (icons.shortcut ??= []).push(item);
|
||||
else if (rel.includes("mask-icon")) (icons.other ??= []).push({ ...item, rel: "mask-icon", ...(icon.color ? { color: icon.color } : {}) });
|
||||
else (icons.icon ??= []).push(item);
|
||||
}
|
||||
if (Object.keys(icons).length) metadata.icons = icons;
|
||||
if (report.manifest) metadata.manifest = report.manifest.localPath || report.manifest.href;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function metadataExport(report: SeoInventory): string {
|
||||
return `export const metadata = ${JSON.stringify(metadataObject(report), null, 2)};\n`;
|
||||
}
|
||||
|
||||
export function viewportExport(report: SeoInventory): string {
|
||||
const viewport: Record<string, unknown> = { width: "device-width", initialScale: 1 };
|
||||
if (report.themeColor) viewport.themeColor = report.themeColor;
|
||||
if (report.colorScheme) viewport.colorScheme = report.colorScheme;
|
||||
return `export const viewport = ${JSON.stringify(viewport, null, 2)};\n`;
|
||||
}
|
||||
|
||||
function safeJsonLd(text: string): string {
|
||||
return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--");
|
||||
}
|
||||
|
||||
export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string {
|
||||
if (!report.jsonLd.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
return report.jsonLd.map((entry, index) => `${pad}<script
|
||||
${pad} key="ditto-json-ld-${index}"
|
||||
${pad} type="application/ld+json"
|
||||
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }}
|
||||
${pad}/>`).join("\n");
|
||||
}
|
||||
|
||||
function resourceText(report: SeoInventory, kind: SeoResource["kind"]): string | null {
|
||||
const resource = report.resources.find((r) => r.kind === kind && r.status === 200 && r.text);
|
||||
return resource?.text ?? null;
|
||||
}
|
||||
|
||||
function plainTextRoute(text: string): string {
|
||||
return `export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return new Response(${JSON.stringify(text)}, {
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function originOf(url: string): string {
|
||||
try { return new URL(url).origin; } catch { return "https://example.com"; }
|
||||
}
|
||||
|
||||
function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string {
|
||||
const title = report.title || routes[0]?.title || "Generated Clone";
|
||||
const lines: string[] = [`# ${title}`, ""];
|
||||
if (report.description) lines.push(report.description, "");
|
||||
lines.push("This is a generated ditto.site clone. It preserves captured page content, metadata, route structure, and static assets where available.", "");
|
||||
lines.push("## Routes", "");
|
||||
for (const route of routes) {
|
||||
const label = route.title || route.routePath || route.href;
|
||||
const desc = route.description ? ` - ${route.description}` : "";
|
||||
lines.push(`- [${label}](${route.url})${desc}`);
|
||||
}
|
||||
const contentRoutes = routes.filter((route) => route.excerpt);
|
||||
if (contentRoutes.length) {
|
||||
lines.push("", "## Captured Content", "");
|
||||
for (const route of contentRoutes.slice(0, 8)) {
|
||||
lines.push(`### ${route.title || route.routePath}`);
|
||||
lines.push(route.excerpt);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
const origin = originOf(report.sourceUrl);
|
||||
const sitemapUrl = origin + "/sitemap.xml";
|
||||
const robots = `import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: ${JSON.stringify(sitemapUrl)},
|
||||
};
|
||||
}
|
||||
`;
|
||||
const sitemapEntries = (routes.length ? routes : [{
|
||||
routePath: "/",
|
||||
href: "/",
|
||||
url: report.canonicalUrl || report.sourceUrl || origin + "/",
|
||||
title: report.title || "Home",
|
||||
description: report.description,
|
||||
excerpt: "",
|
||||
}]).map((route, index) => ({ url: route.url, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
|
||||
const sitemap = `import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return ${JSON.stringify(sitemapEntries, null, 2)};
|
||||
}
|
||||
`;
|
||||
const sourceLlms = resourceText(report, "llms");
|
||||
const sourceLlmsFull = resourceText(report, "llms-full");
|
||||
const files: Array<[string, string]> = [
|
||||
["robots.ts", robots],
|
||||
["sitemap.ts", sitemap],
|
||||
[join("llms.txt", "route.ts"), plainTextRoute(sourceLlms ?? generatedLlms(report, routes))],
|
||||
];
|
||||
if (sourceLlmsFull) files.push([join("llms-full.txt", "route.ts"), plainTextRoute(sourceLlmsFull)]);
|
||||
return files;
|
||||
}
|
||||
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
const origin = originOf(report.sourceUrl);
|
||||
const sitemapUrl = origin + "/sitemap.xml";
|
||||
const robots = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
"",
|
||||
].join("\n");
|
||||
const sitemapRoutes = routes.length ? routes : [{
|
||||
routePath: "/",
|
||||
href: "/",
|
||||
url: report.canonicalUrl || report.sourceUrl || origin + "/",
|
||||
title: report.title || "Home",
|
||||
description: report.description,
|
||||
excerpt: "",
|
||||
}];
|
||||
const sitemap = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...sitemapRoutes.map((route, index) => [
|
||||
" <url>",
|
||||
` <loc>${xmlEscape(route.url)}</loc>`,
|
||||
" <changefreq>weekly</changefreq>",
|
||||
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
|
||||
" </url>",
|
||||
].join("\n")),
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
const sourceLlms = resourceText(report, "llms");
|
||||
const sourceLlmsFull = resourceText(report, "llms-full");
|
||||
const files: Array<[string, string]> = [
|
||||
["robots.txt", robots],
|
||||
["sitemap.xml", sitemap],
|
||||
["llms.txt", sourceLlms ?? generatedLlms(report, routes)],
|
||||
];
|
||||
if (sourceLlmsFull) files.push(["llms-full.txt", sourceLlmsFull]);
|
||||
return files;
|
||||
}
|
||||
|
||||
function storedAssetPath(sourceDir: string, entry: AssetEntry): string | null {
|
||||
if (!entry.storedFile) return null;
|
||||
const store = join(sourceDir, "assets-store", entry.storedFile);
|
||||
if (fileExists(store)) return store;
|
||||
const css = join(sourceDir, "capture", "css", entry.storedFile);
|
||||
return fileExists(css) ? css : null;
|
||||
}
|
||||
|
||||
function extFor(entry: AssetEntry, href: string): string {
|
||||
const fromStored = entry.storedFile ? extname(entry.storedFile).toLowerCase() : "";
|
||||
if (fromStored) return fromStored;
|
||||
try { return extname(new URL(href).pathname).toLowerCase(); } catch { return extname(href).toLowerCase(); }
|
||||
}
|
||||
|
||||
function copyIcon(appDir: string, sourceDir: string, entry: AssetEntry | undefined, href: string, destName: string): boolean {
|
||||
if (!entry) return false;
|
||||
const src = storedAssetPath(sourceDir, entry);
|
||||
if (!src) return false;
|
||||
const dest = join(appDir, "src", "app", destName);
|
||||
ensureDir(join(dest, ".."));
|
||||
copyFileSync(src, dest);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function emitSeoAssetFiles(appDir: string, sourceDir: string, assetGraph: AssetGraph, report: SeoInventory): void {
|
||||
const appRoot = join(appDir, "src", "app");
|
||||
if (fileExists(appRoot)) {
|
||||
for (const name of readdirSync(appRoot)) {
|
||||
if (/^(?:favicon\.ico|icon\d*\.(?:ico|jpg|jpeg|png|svg)|apple-icon\d*\.(?:jpg|jpeg|png))$/.test(name)) {
|
||||
rmSync(join(appRoot, name), { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
const firstIcon = report.icons.find((icon) => /\bicon\b/i.test(icon.rel) && !/apple-touch-icon|mask-icon/i.test(icon.rel));
|
||||
const firstIco = report.icons.find((icon) => /\.ico(?:$|[?#])/i.test(icon.href) || lower(icon.type).includes("icon"));
|
||||
if (firstIco) copyIcon(appDir, sourceDir, assetFor(assetGraph, firstIco.href), firstIco.href, "favicon.ico");
|
||||
if (firstIcon) {
|
||||
const entry = assetFor(assetGraph, firstIcon.href);
|
||||
const ext = entry ? extFor(entry, firstIcon.href) : "";
|
||||
if (/^\.(?:ico|jpg|jpeg|png|svg)$/.test(ext)) copyIcon(appDir, sourceDir, entry, firstIcon.href, `icon${ext}`);
|
||||
}
|
||||
const apple = report.icons.find((icon) => /apple-touch-icon/i.test(icon.rel));
|
||||
if (apple) {
|
||||
const entry = assetFor(assetGraph, apple.href);
|
||||
const ext = entry ? extFor(entry, apple.href) : "";
|
||||
if (/^\.(?:jpg|jpeg|png)$/.test(ext)) copyIcon(appDir, sourceDir, entry, apple.href, `apple-icon${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitSeoRoutes(appDir: string, report: SeoInventory, routes: SeoRouteSummary[]): void {
|
||||
rmSync(join(appDir, "src", "app", "llms-full.txt"), { recursive: true, force: true });
|
||||
for (const [rel, body] of seoRouteFiles(report, routes)) writeText(join(appDir, "src", "app", rel), body);
|
||||
}
|
||||
Reference in New Issue
Block a user