feature: allow for fallback includes

This commit is contained in:
Andy Burke 2026-01-26 23:32:51 -08:00
parent d07991bc60
commit 014d0e56c8
8 changed files with 93 additions and 68 deletions

View file

@ -37,14 +37,17 @@ The default handlers are:
``` ```
<html> <html>
<body> <body>
<!-- #include file="./header.html" --> <!-- #include "./header.html" -->
<!-- can include markdown, which will be converted to html --> <!-- can include markdown, which will be converted to html -->
<!-- #include file="./essay.md" --> <!-- #include "./essay.md" -->
<div id="footer"> <div id="footer">
<!-- you can include text files as well --> <!-- you can include text files as well -->
<!-- #include file="./somedir/footer.txt" > <!-- #include "./somedir/footer.txt" -->
<!-- you can chain includes to allow for local overrides or temporary notices -->
<!-- #include "./news.html" or "./default.html" -->
</div> </div>
</body> </body>
</html> </html>

View file

@ -1,7 +1,7 @@
{ {
"name": "@andyburke/serverus", "name": "@andyburke/serverus",
"description": "A flexible HTTP server for mixed content. Throw static files, markdown, Typescript and (hopefully, eventually) more into a directory and serverus can serve it up a bit more like old-school CGI.", "description": "A flexible HTTP server for mixed content. Throw static files, markdown, Typescript and (hopefully, eventually) more into a directory and serverus can serve it up a bit more like old-school CGI.",
"version": "0.15.0", "version": "0.16.0",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
".": "./serverus.ts", ".": "./serverus.ts",

View file

@ -3,11 +3,16 @@
* @module * @module
*/ */
import * as path from '@std/path'; import * as fs from "@std/fs";
import { md_to_html } from './markdown.ts'; import * as path from "@std/path";
import { md_to_html } from "./markdown.ts";
// https://stackoverflow.com/a/75205316 // https://stackoverflow.com/a/75205316
const replaceAsync = async (str: string, regex: RegExp, asyncFn: (match: any, ...args: any) => Promise<any>) => { const replaceAsync = async (
str: string,
regex: RegExp,
asyncFn: (match: any, ...args: any) => Promise<any>,
) => {
const promises: Promise<any>[] = []; const promises: Promise<any>[] = [];
str.replace(regex, (match, ...args) => { str.replace(regex, (match, ...args) => {
promises.push(asyncFn(match, ...args)); promises.push(asyncFn(match, ...args));
@ -17,24 +22,43 @@ const replaceAsync = async (str: string, regex: RegExp, asyncFn: (match: any, ..
return str.replace(regex, () => data.shift()); return str.replace(regex, () => data.shift());
}; };
const SSI_REGEX: RegExp = /\<\!--\s+#include\s+(?<type>.*?)\s*=\s*["'](?<location>.*?)['"]\s+-->/mg;
export async function load_html_with_ssi(html_file: string): Promise<string> { export async function load_html_with_ssi(html_file: string): Promise<string> {
const directory = path.dirname(html_file);
const html_file_content: string = await Deno.readTextFile(html_file); const html_file_content: string = await Deno.readTextFile(html_file);
const processed: string = await replaceAsync( const processed: string = await replaceAsync(
html_file_content, html_file_content,
SSI_REGEX, /\<\!--\s+#include\s+(?<directive>.*?)\s*-->/gm,
async (_match, type, location, index): Promise<string | undefined> => { async (_match, directive, index): Promise<string | undefined> => {
switch (type) { const file_include_options = Array.from(
case 'file': { directive.matchAll(/(?<option>(?<op>or)?\s*["'](?<location>[^"']*?)['"])+/gm),
const directory = path.dirname(html_file); ).map((match) => match?.groups?.location);
let include_location;
for (const location of file_include_options) {
const resolved = path.resolve(path.join(directory, location)); const resolved = path.resolve(path.join(directory, location));
if (!resolved.startsWith(Deno.cwd())) { if (!resolved.startsWith(Deno.cwd())) {
console.error( console.error(
`Cannot include files above the working directory (${Deno.cwd()}): ${location} ${html_file}:${index}` `Cannot include files above the working directory (${Deno.cwd()}): ${location} ${html_file}:${index}`,
); );
break; break;
} }
const exists = await fs.exists(resolved);
if (!exists) {
continue;
}
include_location = resolved;
break;
}
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> = { const HANDLERS: Record<string, (include_path: string) => Promise<string> | string> = {
default: async (include_path: string) => { default: async (include_path: string) => {
try { try {
@ -45,25 +69,22 @@ export async function load_html_with_ssi(html_file: string): Promise<string> {
} }
}, },
'.md': async (include_path: string) => { ".md": async (include_path: string) => {
const markdown_content = await Deno.readTextFile(include_path); const markdown_content = await Deno.readTextFile(include_path);
return md_to_html(markdown_content); return md_to_html(markdown_content);
}, },
'.html': load_html_with_ssi ".html": load_html_with_ssi,
}; };
const extension = path.extname(resolved); const extension = path.extname(include_location);
const handler = extension ? (HANDLERS[extension.toLowerCase()] ?? HANDLERS.default) : HANDLERS.default; const handler = extension
return await handler(resolved); ? (HANDLERS[extension.toLowerCase()] ?? HANDLERS.default)
} : HANDLERS.default;
default: { const result = await handler(include_location);
console.error(`Unknown include type: ${type} ${html_file}:${index}`); return result;
break; },
}
}
}
); );
return processed; return processed;
@ -77,16 +98,17 @@ export async function load_html_with_ssi(html_file: string): Promise<string> {
*/ */
export default async function handle_html(request: Request): Promise<Response | undefined> { export default async function handle_html(request: Request): Promise<Response | undefined> {
const url = new URL(request.url); const url = new URL(request.url);
const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, '')); const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, ""));
if (!normalized_path.startsWith(Deno.cwd())) { if (!normalized_path.startsWith(Deno.cwd())) {
return; return;
} }
const initial_extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? ''; 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; const target_path: string =
initial_extension.length === 0 ? path.join(normalized_path, "index.html") : normalized_path;
const extension = path.extname(target_path).slice(1).toLowerCase(); const extension = path.extname(target_path).slice(1).toLowerCase();
if (extension !== 'html') { if (extension !== "html") {
return; return;
} }
@ -98,21 +120,21 @@ export default async function handle_html(request: Request): Promise<Response |
const processed: string = await load_html_with_ssi(target_path); const processed: string = await load_html_with_ssi(target_path);
const accepts: string = request.headers.get('accept') ?? 'text/html'; const accepts: string = request.headers.get("accept") ?? "text/html";
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
switch (accepts) { switch (accepts) {
case 'text/plain': case "text/plain":
headers['Content-Type'] = 'text/plain'; headers["Content-Type"] = "text/plain";
break; break;
case 'text/html': case "text/html":
default: default:
headers['Content-Type'] = 'text/html'; headers["Content-Type"] = "text/html";
break; break;
} }
return new Response(processed, { return new Response(processed, {
headers headers,
}); });
} catch (error) { } catch (error) {
if (error instanceof Deno.errors.NotFound) { if (error instanceof Deno.errors.NotFound) {
@ -120,11 +142,11 @@ export default async function handle_html(request: Request): Promise<Response |
} }
if (error instanceof Deno.errors.PermissionDenied) { if (error instanceof Deno.errors.PermissionDenied) {
return new Response('Permission Denied', { return new Response("Permission Denied", {
status: 400, status: 400,
headers: { headers: {
'Content-Type': 'text/plain' "Content-Type": "text/plain",
} },
}); });
} }

View file

@ -2,8 +2,8 @@
<body> <body>
<p>Hello. An include should follow:</p> <p>Hello. An include should follow:</p>
<!-- #include file="./test_include.html" --> <!-- #include "./non-existent-include.txt" or "./test_include.html" -->
<!-- #include file="./yet_another_include.html" --> <!-- #include "./yet_another_include.html" -->
<p>Goodbye. The includes should be above.</p> <p>Goodbye. The includes should be above.</p>
</body> </body>
</html> </html>

View file

@ -1,3 +1,3 @@
<div>Include #1</div> <div>Include #1</div>
<!-- #include file="./subdir/another_include.html" --> <!-- #include "./subdir/another_include.html" -->

View file

@ -2,10 +2,10 @@
<body> <body>
<p>Hello. Markdown should follow:</p> <p>Hello. Markdown should follow:</p>
<!-- #include file="./some_markdown.md" --> <!-- #include "./some_markdown.md" -->
<!-- #include file="./subdir/a-random-text-file.txt" --> <!-- #include "./subdir/a-random-text-file.txt" -->
<!-- #include file="./test_include.html" --> <!-- #include "./test_include.html" -->
<!-- #include file="./yet_another_include.html" --> <!-- #include "./yet_another_include.html" -->
<p>Goodbye. The include should be above.</p> <p>Goodbye. The include should be above.</p>
</body> </body>

View file

@ -2,7 +2,7 @@
<body> <body>
<p>Hello. Markdown should follow:</p> <p>Hello. Markdown should follow:</p>
<!-- #include file="./some_markdown.md" --> <!-- #include "./some_markdown.md" -->
<p>Goodbye. The include should be above.</p> <p>Goodbye. The include should be above.</p>
</body> </body>

View file

@ -2,7 +2,7 @@
<body> <body>
<p>Hello. Text should follow:</p> <p>Hello. Text should follow:</p>
<!-- #include file="./subdir/a-random-text-file.txt" --> <!-- #include "./subdir/a-random-text-file.txt" -->
<p>Goodbye. The include should be above.</p> <p>Goodbye. The include should be above.</p>
</body> </body>