serverus/handlers/markdown.ts

406 lines
6.7 KiB
TypeScript

/**
* Default handler for returning Markdown as either HTML or raw Markdown.
* @module
*/
import * as path from '@std/path';
/**
* Handles requests for markdown files, converting them to html by default
* but allowing for getting the raw file with an accept header of text/markdown.
*
* @param request The incoming HTTP request
* @returns Either a response (a markdown file was requested and returned properly) or undefined if unhandled.
*/
export default async function handle_markdown(request: Request): Promise<Response | undefined> {
const url = new URL(request.url);
const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, ''));
if (!normalized_path.startsWith(Deno.cwd())) {
return;
}
try {
const stat = await Deno.stat(normalized_path);
let markdown: string | null = null;
if (stat.isDirectory) {
const index_path = path.join(normalized_path, 'index.md');
const index_stat = await Deno.stat(index_path);
if (!index_stat.isFile) {
return;
}
markdown = await Deno.readTextFile(index_path);
}
if (stat.isFile) {
const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? '';
if (extension !== 'md') {
return;
}
markdown = await Deno.readTextFile(normalized_path);
}
const accepts: string = request.headers.get('accept') ?? 'text/html';
switch (accepts) {
case 'text/markdown': {
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown'
}
});
}
case 'text/html':
default: {
const html = md_to_html(markdown ?? '');
const css = Deno.env.get('SERVERUS_MARKDOWN_CSS') ?? DEFAULT_CSS;
return new Response(
`
<html>
<head>
<!-- CSS -->
${css}
<!-- END CSS -->
</head>
<body>
<div class="markdown">
${html}
</div>
</body>
</html>`,
{
headers: {
'Content-Type': 'text/html'
}
}
);
}
}
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return;
}
if (error instanceof Deno.errors.PermissionDenied) {
return new Response('Permission Denied', {
status: 400,
headers: {
'Content-Type': 'text/plain'
}
});
}
throw error;
}
}
/* DEFAULT CSS */
const DEFAULT_CSS = `
<style type="text/css">
pre,
code {
font-family: Menlo, Monaco, "Courier New", monospace;
}
pre {
padding: 0.5rem;
line-height: 1.25;
overflow-x: scroll;
}
@media print {
*,
*:before,
*:after {
background: transparent !important;
color: #000 !important;
box-shadow: none !important;
text-shadow: none !important;
}
a,
a:visited {
text-decoration: underline;
}
a[href]:after {
content: " (" attr(href) ")";
}
abbr[title]:after {
content: " (" attr(title) ")";
}
a[href^="#"]:after,
a[href^="javascript:"]:after {
content: "";
}
pre,
blockquote {
border: 1px solid #999;
page-break-inside: avoid;
}
thead {
display: table-header-group;
}
tr,
img {
page-break-inside: avoid;
}
img {
max-width: 100% !important;
}
p,
h2,
h3 {
orphans: 3;
widows: 3;
}
h2,
h3 {
page-break-after: avoid;
}
}
a,
a:visited {
color: #01ff70;
}
a:hover,
a:focus,
a:active {
color: #2ecc40;
}
.retro-no-decoration {
text-decoration: none;
}
html {
font-size: 12px;
}
@media screen and (min-width: 32rem) and (max-width: 48rem) {
html {
font-size: 15px;
}
}
@media screen and (min-width: 48rem) {
html {
font-size: 16px;
}
}
body {
line-height: 1.85;
}
p,
.retro-p {
font-size: 1rem;
margin-bottom: 1.3rem;
}
h1,
.retro-h1,
h2,
.retro-h2,
h3,
.retro-h3,
h4,
.retro-h4 {
margin: 1.414rem 0 0.5rem;
font-weight: inherit;
line-height: 1.42;
}
h1,
.retro-h1 {
margin-top: 0;
font-size: 3.998rem;
}
h2,
.retro-h2 {
font-size: 2.827rem;
}
h3,
.retro-h3 {
font-size: 1.999rem;
}
h4,
.retro-h4 {
font-size: 1.414rem;
}
h5,
.retro-h5 {
font-size: 1.121rem;
}
h6,
.retro-h6 {
font-size: 0.88rem;
}
small,
.retro-small {
font-size: 0.707em;
}
/* https://github.com/mrmrs/fluidity */
img,
canvas,
iframe,
video,
svg,
select,
textarea {
max-width: 100%;
}
html,
body {
background-color: #222;
min-height: 100%;
}
html {
font-size: 18px;
}
body {
color: #fafafa;
font-family: "Courier New";
line-height: 1.45;
margin: 6rem auto 1rem;
max-width: 48rem;
padding: 0.25rem;
}
pre {
background-color: #333;
}
blockquote {
border-left: 3px solid #01ff70;
padding-left: 1rem;
}
</style>
`;
/* MARKDOWN TO HTML */
type TRANSFORMER = [RegExp, string];
/* order of these transforms matters, so we list them here in an array and build type from it after. */
const TRANSFORM_NAMES = [
'characters',
'headings',
'horizontal_rules',
'list_items',
'bold',
'italic',
'strikethrough',
'code',
'images',
'links',
'breaks'
] as const;
type TRANSFORM_NAME = typeof TRANSFORM_NAMES[number];
const TRANSFORMS: Record<TRANSFORM_NAME, TRANSFORMER[]> = {
characters: [
[/&/g, '&amp;'],
[/</g, '&lt;'],
[/>/g, '&gt;'],
[/"/g, '&quot;'],
[/'/g, '&#39;']
],
headings: [
[/^#\s(.+)$/gm, '<h1>$1</h1>\n'],
[/^##\s(.+)$/gm, '<h2>$1</h2>\n'],
[/^###\s(.+)$/gm, '<h3>$1</h3>\n'],
[/^####\s(.+)$/gm, '<h4>$1</h4>\n'],
[/^#####\s(.+)$/gm, '<h5>$1</h5>\n']
],
horizontal_rules: [
[/^----*$/gm, '<hr>\n']
],
list_items: [
[/\n\n([ \t]*)([-\*\.].*?)\n\n/gs, '\n\n<ul>\n$1$2\n</ul>\n\n\n'],
[/^([ \t]*)[-\*\.](\s+.*)$/gm, '<li>$1$2</li>\n']
],
bold: [
[/\*([^\*]+)\*/gm, '<strong>$1</strong>']
],
italic: [
[/_([^_]+)_/gm, '<i>$1</i>']
],
strikethrough: [
[/~([^~]+)~/gm, '<s>$1</s>']
],
code: [
[/```\n([^`]+)\n```/gm, '<pre><code>$1</code></pre>'],
[/```([^`]+)```/gm, '<code>$1</code>']
],
images: [
[/!\[([^\]]+)\]\(([^\)]+)\)/g, '<img src="$2" alt="$1">']
],
links: [
[/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2">$1</a>']
],
breaks: [
[/\s\s\n/g, '\n<br>\n'],
[/\n\n/g, '\n<br>\n']
]
};
/**
* Convert markdown to HTML.
* @param markdown The markdown string.
* @param options _(Optional)_ A record of transforms to disable.
* @returns The generated HTML string.
*/
export function md_to_html(
markdown: string,
transform_config?: Record<TRANSFORM_NAME, boolean>
): string {
let html = markdown;
for (const transform_name of TRANSFORM_NAMES) {
const enabled: boolean = typeof transform_config === 'undefined' || transform_config[transform_name] !== false;
if (!enabled) {
continue;
}
const transforms: TRANSFORMER[] = TRANSFORMS[transform_name] ?? [];
for (const markdown_transformer of transforms) {
html = html.replace(...markdown_transformer);
}
}
return html;
}