sitemap-ts
Version:
Sitemap generator for TypeScript projects
228 lines (220 loc) • 8.42 kB
JavaScript
import { writeFileSync } from 'node:fs';
import { SitemapStream, streamToPromise } from 'sitemap';
import format from 'xml-formatter';
import { ensurePrefix, slash, ensureSuffix } from '@antfu/utils';
import { isAbsolute, resolve, parse, join } from 'node:path';
import fg from 'fast-glob';
const defaultOptions = {
hostname: "http://localhost/",
dynamicRoutes: [],
exclude: [],
externalSitemaps: [],
basePath: "",
outDir: "dist",
extensions: "html",
changefreq: "daily",
priority: 1,
lastmod: /* @__PURE__ */ new Date(),
readable: false,
generateRobotsTxt: true,
robots: [{
userAgent: "*",
allow: "/"
}]
};
function resolveOptions(userOptions) {
return Object.assign(
{},
defaultOptions,
userOptions
);
}
function getResolvedPath(file, resolvedOptions) {
if (isAbsolute(resolvedOptions.outDir))
return resolve(`${resolvedOptions.outDir}/${file}`);
return resolve(`${ensurePrefix("./", resolvedOptions.outDir)}/${file}`);
}
function removeMaybeSuffix(suffix, str) {
if (!str.endsWith(suffix))
return str;
return str.slice(0, -suffix.length);
}
var RobotCorrespondences = /* @__PURE__ */ ((RobotCorrespondences2) => {
RobotCorrespondences2["userAgent"] = "User-agent";
RobotCorrespondences2["allow"] = "Allow";
RobotCorrespondences2["disallow"] = "Disallow";
RobotCorrespondences2["crawlDelay"] = "Crawl-delay";
RobotCorrespondences2["cleanParam"] = "Clean-param";
return RobotCorrespondences2;
})(RobotCorrespondences || {});
function getRules(options) {
const rules = [];
options.forEach((rule) => {
const keys = Object.keys(RobotCorrespondences).filter((key) => typeof rule[key] !== "undefined");
keys.forEach((key) => {
const values = Array.isArray(rule[key]) ? rule[key] : [rule[key]];
values.forEach((value) => {
rules.push({
key: RobotCorrespondences[key],
value
});
});
});
});
return rules;
}
function getContent(rules, hostname, externalSitemaps) {
return rules.map((rule) => `${rule.key}: ${String(rule.value).trim()}`).join("\n").concat(`
Sitemap: ${getFinalSitemapPath(hostname)}`).concat(externalSitemaps.map((s) => `
Sitemap: ${s.startsWith("http") ? s : getFinalSitemapPath(hostname, s)}`).join(""));
}
function getFinalSitemapPath(hostname, file = "/sitemap.xml") {
return `${removeMaybeSuffix("/", hostname)}${ensurePrefix("/", file)}`;
}
function getRoutes(options) {
const ext = typeof options.extensions === "string" ? [options.extensions] : options.extensions;
const strExt = ext.map((e) => `**/*.${e}`);
return [
...fg.sync(strExt, { cwd: options.outDir }).map((route) => {
let r = route;
ext.forEach((e) => {
const regex = new RegExp(`index.${e}`, "g");
r = r.replace(regex, "");
});
const parsedRoute = parse(r);
return slash(join("/", parsedRoute.dir, parsedRoute.name));
}),
...options.dynamicRoutes.map((route) => slash(join("/", join(parse(route).dir, parse(route).name))))
].filter((route) => !options.exclude.includes(route));
}
function getOptionByRoute(options, route) {
if (options instanceof Date || typeof options === "string" || typeof options === "number")
return options;
const givenRoutes = Object.keys(options);
if (givenRoutes.includes(route))
return options[route];
if (givenRoutes.includes("*"))
return options["*"];
return void 0;
}
function getFormattedSitemap(options, routes) {
if (options.i18n?.strategy === "prefix_except_default") {
return routes.map(prefixExceptDefaultLanguageFactory(
options,
{
defaultLanguage: options.i18n.defaultLanguage || "en",
languages: options.i18n.languages
}
));
}
return routes.map((route) => {
const hostNamePath = removeMaybeSuffix("/", options.hostname);
const routePath = options.basePath ? ensurePrefix("/", options.basePath) + ensurePrefix("/", route) : ensurePrefix("/", route);
const url = new URL(routePath, hostNamePath).href;
const formattedSitemap = {
url,
changefreq: getOptionByRoute(options.changefreq, route) ?? defaultOptions.changefreq,
priority: getOptionByRoute(options.priority, route) ?? defaultOptions.priority,
lastmod: getOptionByRoute(options.lastmod, route) ?? defaultOptions.lastmod
};
if (options.i18n) {
const strategy = options.i18n.strategy ?? "suffix";
const languages = options.i18n.languages.map((str) => ({
lang: str,
url: str === options.i18n?.defaultLanguage ? url : new URL(strategy === "prefix" ? ensurePrefix("/", str) + routePath : removeMaybeSuffix("/", routePath) + ensurePrefix("/", str), hostNamePath).href
}));
return Object.assign(formattedSitemap, { links: options.i18n.defaultLanguage ? [...languages, { lang: "x-default", url }] : languages });
}
return formattedSitemap;
});
}
function prefixExceptDefaultLanguageFactory(options, i18n) {
const hostNamePath = removeMaybeSuffix("/", options.hostname);
const useBasePath = ensurePrefix("/", ensureSuffix("/", options.basePath));
const contextPath = removeMaybeSuffix("/", useBasePath);
const defaultLanguage = i18n.defaultLanguage || "en";
const locales = i18n.languages.filter((l) => l !== defaultLanguage);
const localePrefixes = locales.map((lang) => {
return [`${contextPath}/${lang}/`, `${contextPath}/${lang}`, lang];
});
return (route) => {
let url;
let pathWithoutLang;
if (route === useBasePath) {
url = ensureSuffix("/", new URL(useBasePath, hostNamePath).href);
pathWithoutLang = "";
} else {
const locale = localePrefixes.find(
([
prefix1,
prefix2
]) => route.startsWith(prefix1) || route.startsWith(prefix2)
);
if (locale) {
url = route.startsWith(locale[0]) ? route.replace(locale[0], "") : route.replace(locale[1], "");
if (url === "") {
pathWithoutLang = "";
url = ensureSuffix("/", new URL(`${useBasePath}${locale[2]}`, hostNamePath).href);
} else {
pathWithoutLang = url[0] === "/" ? url.slice(1) : url;
url = new URL(route, hostNamePath).href;
}
} else {
pathWithoutLang = route.replace(useBasePath, "");
url = new URL(route, hostNamePath).href;
}
}
const trailingSlash = url.at(-1) === "/";
let xDefaultHref = new URL(`${useBasePath}${pathWithoutLang}`, hostNamePath).href;
if (trailingSlash)
xDefaultHref = ensureSuffix("/", xDefaultHref);
const links = [{
hreflang: defaultLanguage,
rel: "alternate",
url: xDefaultHref
}];
for (const l of locales) {
const href = new URL(`${useBasePath}${l}/${pathWithoutLang}`, hostNamePath).href;
links.push({
hreflang: l,
rel: "alternate",
url: trailingSlash ? ensureSuffix("/", href) : href
});
}
links.push({
hreflang: "x-default",
rel: "alternate",
url: xDefaultHref
});
return {
url,
changefreq: getOptionByRoute(options.changefreq, route) ?? defaultOptions.changefreq,
priority: getOptionByRoute(options.priority, route) ?? defaultOptions.priority,
lastmod: getOptionByRoute(options.lastmod, route) ?? defaultOptions.lastmod,
links
};
};
}
function generateSitemap(options = {}) {
const resolvedOptions = resolveOptions(options);
if (resolvedOptions.generateRobotsTxt) {
const robotRules = getRules(resolvedOptions.robots);
const robotContent = getContent(robotRules, resolvedOptions.hostname, resolvedOptions.externalSitemaps);
writeFileSync(getResolvedPath("robots.txt", resolvedOptions), robotContent);
}
const routes = getRoutes(resolvedOptions);
if (!routes.length)
return;
const formattedSitemap = getFormattedSitemap(resolvedOptions, routes);
const stream = new SitemapStream({
xmlns: resolvedOptions.xmlns
});
formattedSitemap.forEach((item) => stream.write(item));
streamToPromise(stream).then((sitemap) => {
const utfSitemap = sitemap.toString("utf-8");
const formattedSitemap2 = resolvedOptions.readable ? format(utfSitemap) : utfSitemap;
writeFileSync(getResolvedPath("sitemap.xml", resolvedOptions), formattedSitemap2);
});
stream.end();
}
export { generateSitemap as default, generateSitemap, getFormattedSitemap, getRoutes, resolveOptions };