You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
305 lines
9.9 KiB
305 lines
9.9 KiB
/*
|
|
Utilities mostly for generating the documentation and static pages in
|
|
most sites. It's targetted at the kind of content I make, which is
|
|
programming docs, educational lessons, and other Markdown documents
|
|
with code in them.
|
|
|
|
The main feature of this module is the `class Ork` which load code from
|
|
external code files for inclusion in the output HTML.
|
|
*/
|
|
import { Remarkable } from "remarkable";
|
|
import assert from "assert";
|
|
// Node is absolutely stupid. The remarkable project has an esm setting which
|
|
// specifies the dist/esm directory, but it completely ignores it and complains.
|
|
import { linkify } from "remarkable/dist/cjs/linkify.js";
|
|
import Prism from "../static/js/prism.cjs";
|
|
import _ from "lodash";
|
|
import slugify from "slugify";
|
|
import fs from "fs";
|
|
import PATH from "path";
|
|
import child_process from "child_process";
|
|
|
|
_.templateSettings.interpolate = /{{([\s\S]+?)}}/g;
|
|
|
|
/*
|
|
This is a little class I use to read in an external code
|
|
file, then wrap that code with the markdown code blocks.
|
|
|
|
Look in `render_with_code` to see how it is implemented:
|
|
|
|
1. Creates a `lodash` template that has a variable named `ork`.
|
|
2. When `render_with_code` runs it then first runs your `.md` file through this template.
|
|
3. That template can then call functions on `Ork`, as in: `{{ ork.js() }}`.
|
|
4. `Ork` then returns whatever Markdown code format is needed for the output.
|
|
*/
|
|
export class Ork {
|
|
|
|
/*
|
|
Given a `base` directory and metadata object it will load code as needed. Keep
|
|
in mind this is tied to my setup where I have a private directory in a separate
|
|
repository that contains the code for the course. This let's you do this:
|
|
|
|
```javascript
|
|
let ork = new Ork("javascript_level_1", { code: "exercise_1"});
|
|
let md_out = ork.js("code.js");
|
|
```
|
|
|
|
This will load the file in `javascript_level_1/exercise_1/code.js`, wrap it with
|
|
Markdown code blocks.
|
|
|
|
+ `base string` -- The base directory where all of the code examples are.
|
|
+ `metadata Object` -- When a `.md` file is loaded it has metadata at the top, and one is `code` which says where code for that `.md` file should be loaded.
|
|
|
|
___BUG___: `base` needs to go away, but for now it's where the root of the db is.
|
|
*/
|
|
constructor(base, metadata) {
|
|
if(metadata.code) {
|
|
this.code_path = `${base}${metadata.code}`;
|
|
} else {
|
|
this.code_path = false;
|
|
}
|
|
}
|
|
|
|
/*
|
|
Loads a JavaScript file and returns it wrapped in a Markdown code block tagged
|
|
with `javascript`.
|
|
|
|
+ `file string` -- The file to load relative to `base/{metadata.code}` from the constructor.
|
|
*/
|
|
js(file='code.js') {
|
|
assert(this.code_path, "You are using ork but no code path set in metadata.");
|
|
return `\`\`\`javascript\n${fs.readFileSync(`${this.code_path}/${file}`).toString().trim()}\n\`\`\``
|
|
}
|
|
|
|
/*
|
|
Actually __executes__ the code file using the local `node` interpreter, and returns
|
|
the output, instead of the code. This lets you automatically generate the results
|
|
of code for your documents from one source. Otherwise you have to do this manually
|
|
then include it.
|
|
|
|
+ `file string` -- The file to _run_ relative to `base/{metadata.code}` from the constructor.
|
|
+ `args string` -- Arguments to the `node` command line.
|
|
*/
|
|
node(file='code.js', args='') {
|
|
assert(this.code_path, "You are using ork but no code path set.");
|
|
let cmd = `node "${file}" ${args}`;
|
|
let result = child_process.execSync(cmd, {cwd: this.code_path});
|
|
return `\`\`\`shell-session\n$ ${cmd}\n${result.toString().trim()}\n\`\`\``
|
|
}
|
|
}
|
|
|
|
/*
|
|
To make CSS line numbers work you have to do this janky line counting
|
|
thing. Definitely not an efficient way to do it but it is a simple way.
|
|
*/
|
|
export const count_lines = (str) => {
|
|
let match = str.match(/\n(?!$)/g);
|
|
return match ? match.length + 1 : 1;
|
|
}
|
|
|
|
/*
|
|
Using `count_lines` this will generate a bunch of `<span></span>` tags
|
|
to match the count. This is the easiest way to create a strip of line
|
|
numbers to the left of a code block.
|
|
*/
|
|
export const line_number_spans = (str) => {
|
|
let count = count_lines(str);
|
|
let spans = new Array(count + 1).join('<span></span>');
|
|
return `<span aria-hidden="true" class="line-numbers-rows">${spans}</span>`;
|
|
}
|
|
|
|
/*
|
|
Runs Prism on the string, defaulting to the "markup" language which will
|
|
encode most code correctly even if you don't specify a language after the
|
|
Markdown code block header.
|
|
|
|
+ `str string` -- Code to parse with Prism.
|
|
+ `lang string` -- Defaults to "markup" but can be anything Prism supports.
|
|
*/
|
|
export const run_prism = (str, lang="markup") => {
|
|
assert(lang !== undefined, "Language can't be undefined.");
|
|
|
|
// do the check again since the language might not exist
|
|
if(Prism.languages[lang]) {
|
|
return Prism.highlight(str, Prism.languages[lang], lang);
|
|
} else {
|
|
console.error("Unknown language", lang);
|
|
return Prism.highlight(str, Prism.languages["markup"], "markup");
|
|
}
|
|
}
|
|
|
|
/*
|
|
Does a complete highlight of a block of code for the specified
|
|
`lang`. It does the combination of `run_prism` + `line_number_spans`
|
|
so that the output is immediately useable as code output with line numbers.
|
|
|
|
+ `str string` -- Code to parse with Prism.
|
|
+ `lang string` -- Can be anything Prism supports, like "javascript" or "shell".
|
|
*/
|
|
export const highlight = (str, lang) => {
|
|
return run_prism(str, lang) + line_number_spans(str);
|
|
}
|
|
|
|
/*
|
|
A separate renderer just for the titles that doesn't need anything else.
|
|
The main thing this does is turn of paragraphs by assigning:
|
|
|
|
```javascript
|
|
rem.renderer.rules.paragraph_open = pass;
|
|
rem.renderer.rules.paragraph_close = pass;
|
|
```
|
|
|
|
The `pass` function is an empty function that removes the paragraphs from the
|
|
Remarkable output. That's all you need for title rendering.
|
|
*/
|
|
export const title_render = new Remarkable('full').use(rem => {
|
|
let pass = (tokens, idx) => '';
|
|
rem.renderer.rules.paragraph_open = pass;
|
|
rem.renderer.rules.paragraph_close = pass;
|
|
});
|
|
|
|
/*
|
|
My Markdown files `.md` files with a format of:
|
|
|
|
```markup
|
|
{
|
|
metadata
|
|
}
|
|
------
|
|
Markdown
|
|
```
|
|
|
|
This function splits the input at the 6 `-` characters, runs
|
|
`JSON.parse` on the top part, then returns that metadata and body.
|
|
|
|
+ `raw_md string` -- The markdown to split.
|
|
+ ___return `Array[Object, string]` -- An Array with the metadata object and the string body.
|
|
*/
|
|
export const split = (raw_md) => {
|
|
let [metadata, ...body] = raw_md.split('------');
|
|
metadata = JSON.parse(metadata);
|
|
body = body.join('------');
|
|
return [metadata, body];
|
|
}
|
|
|
|
const null_cb = (metadata, body) => body;
|
|
|
|
/*
|
|
Constructs the Remarkable render function that will do:
|
|
|
|
1. `linkify` any hostnames it finds.
|
|
2. Update the `toc` Array with new headings for a TOC output.
|
|
3. Fix up links, but I'm not sure why I'm doing this.
|
|
|
|
___BUG___: Why am I changing the link output.
|
|
*/
|
|
export const create_renderer = (toc) => {
|
|
const renderer = new Remarkable('full', {
|
|
html: true,
|
|
highlight
|
|
});
|
|
|
|
renderer.use(linkify);
|
|
|
|
renderer.use(rem => {
|
|
rem.renderer.rules.heading_open = (tokens, idx) => {
|
|
let level = tokens[idx].hLevel;
|
|
let content = tokens[idx + 1].content;
|
|
let slug = slugify(content, {lower: true, strict: true});
|
|
if(toc) toc.push({level, content, slug});
|
|
return `<h${level} id="${slug}">`;
|
|
}
|
|
|
|
rem.renderer.rules.heading_close = (tokens, idx) => {
|
|
return `</h${tokens[idx].hLevel}>\n`;
|
|
}
|
|
});
|
|
|
|
renderer.use(rem => {
|
|
const orig_open = rem.renderer.rules.link_open;
|
|
const orig_close = rem.renderer.rules.link_close;
|
|
|
|
rem.renderer.rules.link_open = (tokens, idx, options) => {
|
|
return orig_open(tokens, idx, options);
|
|
}
|
|
|
|
rem.renderer.rules.link_close = (tokens, idx, options) => {
|
|
return orig_close(tokens, idx, options);
|
|
}
|
|
});
|
|
|
|
return renderer;
|
|
}
|
|
|
|
/*
|
|
Performs a render of the `raw_md` that will:
|
|
|
|
1. Split the `raw_md` with `split`.
|
|
2. Render with a Remarkable renderer from `create_renderer`.
|
|
3. Go through each element of the TOC and run `title_render.render` on them.
|
|
|
|
|
|
+ `raw_md string` -- The Markdown string to parse.
|
|
+ `cb function(metadata, body)` -- An optional callback that gets the `metadata, body` from `split`.
|
|
+ ___return___ `{toc: Array, content: string, metadata: Object}`
|
|
*/
|
|
export const render = (raw_md, cb=null_cb) => {
|
|
let toc = [];
|
|
let [metadata, body] = split(raw_md);
|
|
|
|
const renderer = create_renderer(toc);
|
|
let content = renderer.render(cb(metadata, body));
|
|
|
|
// now we can use the TOC to figure out a title
|
|
metadata.title = toc[0].content;
|
|
|
|
try {
|
|
// finally, run the renderer on all of the TOC
|
|
toc.forEach(t => t.html = title_render.render(t.content));
|
|
} catch(error) {
|
|
console.error(error);
|
|
}
|
|
|
|
metadata.original_md = body;
|
|
|
|
return {toc, content, metadata};
|
|
}
|
|
|
|
/*
|
|
The tip of the iceberg, this does the actual render of a markdown file
|
|
with source code in it. It combines all of the functions so far into
|
|
one cohesive rendering function:
|
|
|
|
1. It uses `Ork` to load code into the markdown.
|
|
2. It uses `render` to create a Remarkable renderer.
|
|
3. Finally uses `slugify` to generate a slug for the metadata output.
|
|
|
|
+ `source_dir string` -- Source code directory for the `Ork(source_dir, metadata)` call.
|
|
+ `md_file string` -- Path to the markdown file to load.
|
|
+ ___return___ `{content: string, toc: Array, metadata: Object}`
|
|
*/
|
|
export const render_with_code = (source_dir, md_file) => {
|
|
// get the file without path or extension
|
|
const tail_no_ext = PATH.basename(md_file, PATH.extname(md_file));
|
|
|
|
// this is a closure that is passed to render to run Ork for the code wrapping
|
|
const load_code = (metadata, body) => {
|
|
return _.template(body)({ fs, ork: new Ork(source_dir, metadata)});
|
|
}
|
|
|
|
// load the .md file contents
|
|
let raw_md = fs.readFileSync(md_file);
|
|
|
|
let { toc, content, metadata } = render(raw_md.toString(), load_code);
|
|
|
|
metadata.slug = slugify(tail_no_ext, {lower: true, strict: true});
|
|
|
|
return {content, toc, metadata};
|
|
}
|
|
|
|
export default {
|
|
render,
|
|
split,
|
|
render_with_code,
|
|
create_renderer
|
|
}
|
|
|