alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
197 lines (176 loc) • 5.54 kB
text/typescript
import { $inject, createPrimitive, KIND, Primitive } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { type ServerRequest, ServerRouterProvider } from "alepha/server";
/**
* Expose a `sitemap.xml` generated from the application's `$page` primitives.
*
* Registers a `GET /sitemap.xml` route that reads every registered page at
* request time and emits a standard XML sitemap. Marked `static` by default, so
* the build prerenders it to `dist/public/sitemap.xml` for static deployments —
* while SSR runtimes also serve it live.
*
* The hostname comes from `options.hostname`, falling back to `PUBLIC_URL`, then
* to `""` (relative URLs).
*
* @example
* ```ts
* import { $sitemap } from "alepha/react/sitemap";
*
* class AppRouter {
* sitemap = $sitemap();
* }
* ```
*/
export const $sitemap = (
options: SitemapPrimitiveOptions = {},
): SitemapPrimitive => {
return createPrimitive(SitemapPrimitive, options);
};
// ---------------------------------------------------------------------------------------------------------------------
export interface SitemapPrimitiveOptions {
/**
* Absolute base URL used to build `<loc>` entries (e.g. "https://alepha.dev").
*
* Defaults to `PUBLIC_URL`, then to `""` (relative URLs).
*/
hostname?: string;
/**
* Route path the sitemap is served at.
*
* @default "/sitemap.xml"
*/
path?: string;
/**
* Prerender the sitemap to a static file at build time.
*
* @default true
*/
static?: boolean;
}
// ---------------------------------------------------------------------------------------------------------------------
export class SitemapPrimitive extends Primitive<SitemapPrimitiveOptions> {
protected readonly router = $inject(ServerRouterProvider);
protected readonly dateTime = $inject(DateTimeProvider);
protected onInit() {
this.router.createRoute({
method: "GET",
path: this.options.path ?? "/sitemap.xml",
static: this.options.static ?? true,
silent: true,
handler: (request: ServerRequest) => {
request.reply.setHeader("content-type", "application/xml");
return this.buildSitemap();
},
});
}
/**
* Render the sitemap to its path and body. Used by the build to snapshot the
* sitemap to a static file.
*/
public prerender(): { path: string; body: string } {
return {
path: this.options.path ?? "/sitemap.xml",
body: this.buildSitemap(),
};
}
/**
* Build the sitemap XML from the application's page primitives.
*/
protected buildSitemap(): string {
const hostname =
this.options.hostname ?? String(this.alepha.env.PUBLIC_URL ?? "");
const pages = this.getSitemapPages();
return this.generateSitemapFromPages(pages, hostname);
}
/**
* Select the pages that should appear in the sitemap.
*
* Excludes layout pages (with `children`), wildcard paths, and `/404`.
* Parameterized pages are included only when they declare `static.entries`.
*/
protected getSitemapPages(): any[] {
const pages = this.alepha.primitives("page") as any[];
return pages.filter((page) => {
const options = page.options;
const path: string = options.path ?? "";
if (options.children) {
return false;
}
if (path.includes("*")) {
return false;
}
if (path === "/404") {
return false;
}
if (!options.schema?.params) {
return true;
}
if (
options.static &&
typeof options.static === "object" &&
options.static.entries
) {
return true;
}
return false;
});
}
protected generateSitemapFromPages(pages: any[], baseUrl: string): string {
const urls: string[] = [];
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
for (const page of pages) {
const options = page.options;
if (!options.schema?.params) {
const path = options.path || "";
const url = `${normalizedBaseUrl}${path === "" ? "/" : path}`;
urls.push(url);
} else if (
options.static &&
typeof options.static === "object" &&
options.static.entries
) {
for (const entry of options.static.entries) {
const path = this.buildPathFromParams(
options.path || "",
entry.params || {},
);
const url = `${normalizedBaseUrl}${path}`;
urls.push(url);
}
}
}
return this.buildSitemapXml(urls);
}
protected buildPathFromParams(
pathPattern: string,
params: Record<string, any>,
): string {
let path = pathPattern;
for (const [key, value] of Object.entries(params)) {
path = path.replace(`:${key}`, String(value));
}
return path || "/";
}
protected buildSitemapXml(urls: string[]): string {
const lastMod = this.dateTime.now().format("YYYY-MM-DD");
const urlEntries = urls
.map(
(url) =>
` <url>\n <loc>${this.escapeXml(url)}</loc>\n <lastmod>${lastMod}</lastmod>\n </url>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlEntries}
</urlset>`;
}
protected escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
$sitemap[KIND] = SitemapPrimitive;