298 lines
8.7 KiB
TypeScript
298 lines
8.7 KiB
TypeScript
|
|
import { marked } from 'marked';
|
|
import { highlight } from './prism';
|
|
import katex = require('katex');
|
|
import type { KatexOptions } from 'katex';
|
|
import render_bytefield = require('bytefield-svg');
|
|
import { renderSvg as render_nomnoml } from 'nomnoml';
|
|
import { pikchr } from 'pikchr';
|
|
import { parse as parse_yaml } from 'yaml';
|
|
import { icons } from './icons';
|
|
import { strip_svg } from './svg';
|
|
import { generate_mecard_qr_code, generate_qr_code } from './qrcode';
|
|
import { bind_data_async } from './async-steps';
|
|
import { render_vega_spec } from './vega';
|
|
import { parse_attributes } from './attrs';
|
|
import { MarkdownOptions } from './render';
|
|
|
|
export function create_renderer(opts: MarkdownOptions) {
|
|
const renderer = new marked.Renderer();
|
|
|
|
renderer.heading = heading(renderer, opts);
|
|
renderer.code = code(renderer, opts);
|
|
|
|
// ...
|
|
|
|
return renderer;
|
|
}
|
|
|
|
function heading(renderer: marked.Renderer, opts: MarkdownOptions) {
|
|
return function(orig_text: string, level: 1 | 2 | 3 | 4 | 5 | 6, raw: string, slugger) {
|
|
let { text, id, html_attrs } = parse_attributes(raw);
|
|
|
|
if (! id) {
|
|
id = slugger.slug(text);
|
|
html_attrs.push(`id="${id}"`);
|
|
}
|
|
|
|
return `
|
|
<h${level} ${html_attrs.join(' ')}>
|
|
${text}
|
|
<a class="heading-anchor" href="#${id}">
|
|
${icons.link}
|
|
<span style="display: none">Section titled ${text}</span>
|
|
</a>
|
|
</h${level}>
|
|
`;
|
|
};
|
|
}
|
|
|
|
function code(renderer: marked.Renderer, opts: MarkdownOptions) {
|
|
return function(code: string, infostring: string, is_escaped: boolean) {
|
|
const args = parse_code_args(infostring);
|
|
|
|
if (! args || ! args[0]) {
|
|
return `<pre class="language-txt"><code>${escape(code, is_escaped)}</code></pre>`;
|
|
}
|
|
|
|
let caption = '';
|
|
const flags = new Set<string>();
|
|
|
|
for (let i = 1; i < args.length; i++) {
|
|
if (args[i][0] === ':') {
|
|
flags.add(args[i]);
|
|
}
|
|
|
|
else {
|
|
caption = `<figcaption>${marked.parseInline(args[i], renderer.options)}</figcaption>`;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const size
|
|
= flags.has(':small') ? 'small'
|
|
: flags.has(':medium') ? 'medium'
|
|
: flags.has(':large') ? 'large'
|
|
: flags.has(':full') ? 'full'
|
|
: 'medium';
|
|
|
|
const figure = (content: string) => `<figure data-lang="${args[0]}" data-size="${size}">${content}${caption}</figure>`;
|
|
|
|
if (args[0].startsWith('http:')) {
|
|
return render_http_with_content(code, args[0].slice(5));
|
|
}
|
|
|
|
switch (args[0]) {
|
|
case 'samp':
|
|
return figure(`<pre class="language-txt"><samp>${escape(code, is_escaped)}</samp></pre>`);
|
|
|
|
case 'bash:samp': {
|
|
// Find the first newline that is not preceeded by a "\"
|
|
const end_of_input = /(?<!\\)(?:\r\n|\r|\n)/.exec(code);
|
|
|
|
// If there is no such newline, the whole content is input
|
|
if (! end_of_input) {
|
|
return figure(`<pre class="language-bash">${render_prism(code, 'bash')}</pre>`);
|
|
}
|
|
|
|
const input = code.slice(0, end_of_input.index);
|
|
const rendered_input = render_prism(input, 'bash');
|
|
const output = code.slice(end_of_input.index + 1);
|
|
const rendered_output = `<samp>${escape(output, is_escaped)}</samp>`;
|
|
|
|
return figure(`<pre class="language-bash">${rendered_input}\n${rendered_output}</pre>`);
|
|
};
|
|
|
|
case 'katex': {
|
|
const katex_opts: KatexOptions = {
|
|
displayMode: true, // true == "block"
|
|
output: 'html',
|
|
macros: opts.katex_macros,
|
|
};
|
|
|
|
return figure(
|
|
(katex as any).renderToString(code, katex_opts)
|
|
);
|
|
};
|
|
|
|
case 'nomnoml': {
|
|
const svg = render_nomnoml(code);
|
|
return figure(post_process_nomnoml_svg(svg));
|
|
};
|
|
|
|
case 'clojure:bytefield': {
|
|
const svg = render_bytefield(code);
|
|
return figure(post_process_bytefield_svg(svg));
|
|
};
|
|
|
|
case 'pikchr': {
|
|
const svg = pikchr(code);
|
|
return figure(post_process_pikchr_svg(svg));
|
|
};
|
|
|
|
case 'qrcode': {
|
|
const promise = generate_qr_code(code);
|
|
const async_binding = bind_data_async(promise);
|
|
return figure(async_binding);
|
|
};
|
|
|
|
case 'yaml:mecard': {
|
|
const parsed = parse_yaml(code);
|
|
const promise = generate_mecard_qr_code(parsed);
|
|
const async_binding = bind_data_async(promise);
|
|
return figure(async_binding);
|
|
};
|
|
|
|
case 'json:vega': {
|
|
const spec = JSON.parse(code);
|
|
const promise = render_vega_spec(spec);
|
|
const binding = bind_data_async(promise);
|
|
return figure(binding);
|
|
};
|
|
|
|
case 'yaml:vega': {
|
|
const spec = parse_yaml(code);
|
|
const promise = render_vega_spec(spec);
|
|
const binding = bind_data_async(promise);
|
|
return figure(binding);
|
|
};
|
|
|
|
default:
|
|
return figure(`<pre class="language-${args[0] || 'txt'}">${render_prism(code, args[0])}</pre>`);
|
|
}
|
|
|
|
function render_http_with_content(code: string, lang: string) {
|
|
// Find the first double newline
|
|
const end_of_header = /(?:\r\n|\r|\n)(?:\r\n|\r|\n)/.exec(code);
|
|
|
|
// If there is no such newline, the whole content is HTTP header
|
|
if (! end_of_header) {
|
|
return figure(`<pre class="language-http">${render_prism(code, 'http')}</pre>`);
|
|
}
|
|
|
|
const header = code.slice(0, end_of_header.index);
|
|
const rendered_header = render_prism(header, 'http', true);
|
|
const content = code.slice(end_of_header.index + 1);
|
|
const rendered_content = render_prism(content, lang, true);
|
|
|
|
return figure(`<pre class="language-http language-${lang}">${rendered_header}\n${rendered_content}</pre>`);
|
|
}
|
|
|
|
function render_prism(code: string, lang: string, include_class = false) {
|
|
const out = highlight(code, lang);
|
|
|
|
if (out != null && out !== code) {
|
|
is_escaped = true;
|
|
code = out;
|
|
}
|
|
|
|
const classname = include_class ? `class="language-${lang}"` : '';
|
|
return `<code ${classname}>${escape(code, is_escaped)}</code>`;
|
|
}
|
|
};
|
|
}
|
|
|
|
const arg_pattern = /^(?:[a-zA-Z0-9_:-]+|"(?:[^"\n]|(?<=\\)")*")/;
|
|
|
|
function parse_code_args(text: string) {
|
|
const args: string[] = [ ];
|
|
|
|
text = text.trim();
|
|
|
|
while (text.length) {
|
|
const match = arg_pattern.exec(text);
|
|
|
|
if (! match) {
|
|
break;
|
|
}
|
|
|
|
if (match[0][0] === '"') {
|
|
args.push(match[0].slice(1, -1));
|
|
}
|
|
|
|
else {
|
|
args.push(match[0]);
|
|
}
|
|
|
|
text = text.slice(match[0].length).trimStart();
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function escape(str: string, is_escaped: boolean) {
|
|
return is_escaped ? str : str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
const svg_text = /<text /gi
|
|
const svg_stroke_000000 = /\bstroke="#000000"/gi;
|
|
|
|
// todo: css variables
|
|
function post_process_bytefield_svg(svg: string, size?: string) {
|
|
svg = strip_svg(svg);
|
|
|
|
svg = svg.replace(svg_text, '<text fill="var(--theme-text-body, currentcolor)" ');
|
|
svg = svg.replace(svg_stroke_000000, 'stroke="var(--theme-line, currentcolor)"');
|
|
|
|
return svg;
|
|
}
|
|
|
|
const svg_fill_33322e = /\bfill="#33322e"/gi;
|
|
const svg_fill_eee8d5 = /\bfill="#eee8d5"/gi;
|
|
const svg_fill_fdf6e3 = /\bfill="#fdf6e3"/gi;
|
|
const svg_stroke_33322e = /\bstroke="#33322E"/gi;
|
|
const svg_font_family_helvetica = /\bfont-family="helvetica"/gi;
|
|
const svg_nomnoml_filled_arrow_head = /<g fill="#33322E">\s*<path d="([^"]+)">\s*<\/path>\s*<\/g>/gi;
|
|
const svg_nomnoml_unfilled_arrow_head = /<path d="([^"]+)">/gi;
|
|
|
|
// todo: css variables
|
|
function post_process_nomnoml_svg(svg: string, size?: string) {
|
|
svg = strip_svg(svg);
|
|
|
|
// nomnoml uses some specific built-in styles for things, which we will be replacing
|
|
// with variables (referencing the color themes) that fall back to safe defaults for
|
|
// rendering the svg in a context that has css (like an RSS feed or other embedded
|
|
// use case)
|
|
|
|
// default text font
|
|
svg = svg.replace(svg_font_family_helvetica, 'font-family="var(--theme-open-sans, helvetica)"');
|
|
|
|
// root-level boxes background
|
|
svg = svg.replace(svg_fill_eee8d5, 'fill="var(--theme-bg-light, transparent)"');
|
|
|
|
// outlines and relationship lines
|
|
svg = svg.replace(svg_stroke_33322e, 'stroke="var(--theme-line, currentcolor)"');
|
|
|
|
// arrow heads
|
|
svg = svg.replace(svg_nomnoml_filled_arrow_head, ($0, $1) => `<path d="${$1}" fill="var(--theme-line, currentcolor)"></path>`);
|
|
svg = svg.replace(svg_nomnoml_unfilled_arrow_head, ($0, $1) => `<path d="${$1}" fill="none">`);
|
|
|
|
// text color
|
|
svg = svg.replace(svg_fill_33322e, 'fill="var(--theme-text-body, currentcolor)"');
|
|
|
|
// nested boxes background
|
|
svg = svg.replace(svg_fill_fdf6e3, 'fill="var(--theme-bg-heavy, transparent)"');
|
|
|
|
return svg;
|
|
}
|
|
|
|
const svg_text_fill_rgb_000 = /\b<text fill="rgb\(0,0,0\)"/gi;
|
|
const svg_fill_rgb_000 = /fill:rgb\(0,0,0\)/gi;
|
|
const svg_stroke_rgb_000 = /stroke:rgb\(0,0,0\)/gi;
|
|
|
|
// todo: css variables
|
|
function post_process_pikchr_svg(svg: string, size?: string) {
|
|
svg = strip_svg(svg);
|
|
|
|
// text
|
|
svg = svg.replace(svg_text_fill_rgb_000, '<text fill="var(--theme-text-body, currentcolor)"');
|
|
|
|
// arrow heads
|
|
svg = svg.replace(svg_fill_rgb_000, 'fill:var(--theme-line, currentcolor)');
|
|
|
|
// lines / boxes
|
|
svg = svg.replace(svg_stroke_rgb_000, 'stroke:var(--theme-line, currentcolor)');
|
|
|
|
return svg;
|
|
}
|