406 lines
6.7 KiB
TypeScript
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, '&'],
|
|
[/</g, '<'],
|
|
[/>/g, '>'],
|
|
[/"/g, '"'],
|
|
[/'/g, ''']
|
|
],
|
|
|
|
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;
|
|
}
|