serverus/handlers/html.ts

156 lines
4.1 KiB
TypeScript
Raw Normal View History

2025-06-30 15:21:07 -07:00
/**
* Default handler for returning HTML with SSI support
* @module
*/
2026-01-26 23:32:51 -08:00
import * as fs from "@std/fs";
import * as path from "@std/path";
import { md_to_html } from "./markdown.ts";
2025-06-30 15:21:07 -07:00
// https://stackoverflow.com/a/75205316
2026-01-26 23:32:51 -08:00
const replaceAsync = async (
str: string,
regex: RegExp,
asyncFn: (match: any, ...args: any) => Promise<any>,
) => {
2025-06-30 15:21:07 -07:00
const promises: Promise<any>[] = [];
str.replace(regex, (match, ...args) => {
promises.push(asyncFn(match, ...args));
return match;
});
const data = await Promise.all(promises);
return str.replace(regex, () => data.shift());
};
export async function load_html_with_ssi(html_file: string): Promise<string> {
2026-01-26 23:32:51 -08:00
const directory = path.dirname(html_file);
2025-06-30 15:21:07 -07:00
const html_file_content: string = await Deno.readTextFile(html_file);
const processed: string = await replaceAsync(
html_file_content,
2026-01-26 23:32:51 -08:00
/\<\!--\s+#include\s+(?<directive>.*?)\s*-->/gm,
async (_match, directive, index): Promise<string | undefined> => {
const file_include_options = Array.from(
directive.matchAll(/(?<option>(?<op>or)?\s*["'](?<location>[^"']*?)['"])+/gm),
).map((match) => match?.groups?.location);
let include_location;
for (const location of file_include_options) {
const resolved = path.resolve(path.join(directory, location));
if (!resolved.startsWith(Deno.cwd())) {
console.error(
`Cannot include files above the working directory (${Deno.cwd()}): ${location} ${html_file}:${index}`,
);
break;
2025-06-30 15:21:07 -07:00
}
2026-01-26 23:32:51 -08:00
const exists = await fs.exists(resolved);
if (!exists) {
continue;
2025-06-30 15:21:07 -07:00
}
2026-01-26 23:32:51 -08:00
include_location = resolved;
break;
2025-06-30 15:21:07 -07:00
}
2026-01-26 23:32:51 -08:00
if (!include_location) {
console.error(
`Cannot locate any of these files to include: ${file_include_options}`,
);
return;
}
const HANDLERS: Record<string, (include_path: string) => Promise<string> | string> = {
default: async (include_path: string) => {
try {
return await Deno.readTextFile(include_path);
} catch (error) {
console.dir({ error });
throw error;
}
},
".md": async (include_path: string) => {
const markdown_content = await Deno.readTextFile(include_path);
return md_to_html(markdown_content);
},
".html": load_html_with_ssi,
};
const extension = path.extname(include_location);
const handler = extension
? (HANDLERS[extension.toLowerCase()] ?? HANDLERS.default)
: HANDLERS.default;
const result = await handler(include_location);
return result;
},
2025-06-30 15:21:07 -07:00
);
return processed;
}
/**
* Handles requests for HTML files, processing any server-side includes
*
* @param request The incoming HTTP request
* @returns Either a response (an HTML file was requested and returned properly) or undefined if unhandled.
*/
export default async function handle_html(request: Request): Promise<Response | undefined> {
const url = new URL(request.url);
2026-01-26 23:32:51 -08:00
const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, ""));
2025-06-30 15:21:07 -07:00
if (!normalized_path.startsWith(Deno.cwd())) {
return;
}
2026-01-26 23:32:51 -08:00
const initial_extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? "";
const target_path: string =
initial_extension.length === 0 ? path.join(normalized_path, "index.html") : normalized_path;
2025-06-30 15:21:07 -07:00
const extension = path.extname(target_path).slice(1).toLowerCase();
2026-01-26 23:32:51 -08:00
if (extension !== "html") {
2025-06-30 15:21:07 -07:00
return;
}
try {
const stat = await Deno.stat(target_path);
if (!stat.isFile) {
return;
}
const processed: string = await load_html_with_ssi(target_path);
2026-01-26 23:32:51 -08:00
const accepts: string = request.headers.get("accept") ?? "text/html";
2025-06-30 15:21:07 -07:00
const headers: Record<string, string> = {};
switch (accepts) {
2026-01-26 23:32:51 -08:00
case "text/plain":
headers["Content-Type"] = "text/plain";
2025-06-30 15:21:07 -07:00
break;
2026-01-26 23:32:51 -08:00
case "text/html":
2025-06-30 15:21:07 -07:00
default:
2026-01-26 23:32:51 -08:00
headers["Content-Type"] = "text/html";
2025-06-30 15:21:07 -07:00
break;
}
return new Response(processed, {
2026-01-26 23:32:51 -08:00
headers,
2025-06-30 15:21:07 -07:00
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return;
}
if (error instanceof Deno.errors.PermissionDenied) {
2026-01-26 23:32:51 -08:00
return new Response("Permission Denied", {
2025-06-30 15:21:07 -07:00
status: 400,
headers: {
2026-01-26 23:32:51 -08:00
"Content-Type": "text/plain",
},
2025-06-30 15:21:07 -07:00
});
}
throw error;
}
}