solcco
Version:
Documentation generator for Solidity with a literary bent.
388 lines (337 loc) • 12.2 kB
JavaScript
const path = require('path');
const debug = require("debug")("solcco");
const fs = require("fs");
const handlebars = require("handlebars");
const prism = require("prismjs");
const markdownIt = require("markdown-it");
/* Load code highlighters and make language map */
const loadLanguages = require("prismjs/components/");
loadLanguages(["solidity"]);
loadLanguages(["javascript"]);
loadLanguages(["typescript"]);
const languages = {
".ts": {name: "typescript", object: prism.languages.typescript},
".js": {name: "javascript", object: prism.languages.javascript},
".sol": {name: "solidity", object: prism.languages.solidity}
};
const runner = (config, input, extra) => {
/* Table of contents */
const toc = [];
const packedFiles = [];
// markdown-it-anchor already has slug deduplication, but every comment
// block creates a new markdown context so it doesn't remember old slugs.
const slugs = new Set();
const slugify = s => {
const slug = encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'));
let key = slug;
let i = 0 ;
while (slugs.has(key)) key = `${slug}-${i++}`;
slugs.add(key);
return key;
};
const isString = s => typeof s === 'string' || s instanceof String;
let code = "";
if (isString(input)) {
if (isString(extra)) { // input is file title, extra is content
input = [{file: input, content: extra}];
} else { // input is content, ignore extra
input = [{file:'stdin', content: input}];
}
} // otherwise assume all is good (input type is [{file:string,content:string}])
for (const {file,content} of input) {
let rest = content;
/* Add file to TOC */
const fileSlug = slugify(file);
toc.push({title: file, slug: fileSlug, tag: 'file'});
/* Configure highlight language */
const language = languages[path.extname(file)];
if (!language) {
throw `Unknown file extension: ${path.extname(file)}`;
}
/* Parser routines */
let results = [];
let _lineno = 1;
/* Error objects */
const parseError = (msg) => {
const line = rest.match(/.*/)[0];
return new Error(`Parse error, file: ${file}, line ${_lineno}: ${msg}\n${line}`);
};
const parse = (pattern, {move = false, buffer = false, error = false}) => {
const match = rest.match(new RegExp(pattern));
if (!match || match.index != 0) {
if (error) {
throw parseError(error);
}
return false;
} else {
if (move) {
rest = rest.slice(match[0].length);
_lineno += Math.max(0,match[0].split('\n').length - 1);
}
if (buffer) {
_buffer = _buffer.concat(match[0]);
}
return match;
}
};
const lookAhead = (pattern) => parse(pattern, {});
const skip = (pattern) => parse(pattern, {move: true});
let _buffer = "";
const buffer = (pattern) => {
parse(pattern, {move: true, buffer: true, error: `${pattern}: cannot buffer, no match`});
};
const tryBuffer = (pattern) => parse(pattern, {move: true, buffer: true});
const bufferString = (string) => {
_buffer = _buffer.concat(string);
};
const isDone = () => rest == "";
const flush = (type,force) => {
debug(`Flushing ${type} at lineno ${_lineno}, force: ${!!force}`);
//if (_buffer !== '' || force) {
const numbuflines = Math.max(0,_buffer.split('\n').length - 1);
results.push({type, content: _buffer, lineno: _lineno-numbuflines});
//}
_buffer = '';
};
// horizontal whitespace
const raw = String.raw;
const singlehws = raw`[^\S\r\n]`;
const hws = raw`${singlehws}*`;
let mode = "base";
/* Core parsing */
while (true) {
debug("at %o", rest.substring(0, 30));
debug(`| mode: ${mode}`);
if (["base", "code"].includes(mode)) {
if (isDone()) {
flush("code");
break;
} else if (lookAhead('"')) {
// ignore comment markers in "" string
//
debug("| double string");
buffer(/"[^]*?(?<!\\)"/);
mode = "code";
} else if (lookAhead("'")) {
// ignore comment markers in '' strings
debug("| single string");
buffer(/'[^]*?(?<!\\)'/); // ?<! is negative lookbehind
mode = "code";
} else if (mode === "base") {
if (lookAhead(raw`${hws}/\*`)) {
// comment block on its own line
debug("| open block comment");
flush("code");
mode = "blockComment";
/* whitespace prefix length.
Comments in solidity block below a "/*" usually start 1 space after the *,
so the left edge is 3 characters to the right of the comment start.
Schematically :
/*<---- comment starts here
|<---- left edge is here
This is important because markdown has significant whitespace prefix.
For instance 4 spaces before a line starts mean a blockquote
*/
modeParam = skip(hws)[0].length + 3;
skip(raw`/\*`);
} else if (lookAhead(raw`${hws}//`)) {
// comment line on its own line.
debug("| standalone line comment");
if (skip(raw`${hws}//${hws}SPDX-License-Identifier:.*\r?\n`)) {
debug("| (excluded (SPDX))");
} else if (lookAhead(raw`${hws}//\+.*\+.*\r?\n`)) {
debug("| (+...+ flag))");
const flag = skip(raw`${hws}//\+(.*?)\+`)[1];
if (flag == "ignore") {
debug("| (+ignore+ flag))");
skip(raw`.*\r?\n`);
} else if (flag == "clear") {
// useful for inserting clearing lines
debug("| (+clear+ flag))");
flush("code");
skip(/[^]*?\r?\n/);
bufferString("");
flush("clear");
//flush("code",true);
} else {
throw parseError(`unknown solcco line comment flag: ${flag}`);
}
//debug('| (excluded (+ flag))');
//buffer(/[^]*?$/m);
} else {
debug("| (included)");
flush("code");
skip(raw`${hws}//${hws}`);
buffer(/[^]*?$/m); // m flag interprets $ as endofline
skip('\r?\n');
flush("lineComment");
}
} else if (lookAhead(/\s/)) {
// whitespace
debug("| whitespace");
buffer(/[^]/);
} else {
// non-whitespace
debug("| non-whitespace, going to code");
mode = "code";
buffer(/[^]/);
}
} else if (mode === "code") {
if (lookAhead(/\/\*/)) {
// embedded comment block (ignored for now)
debug("| excluded block comment");
buffer(raw`/\*[^]*?\*/`);
} else if (lookAhead(raw`//`)) {
debug("| excluded line comment, going to base");
buffer(raw`//[^]*?\r?\n`);
mode = "base";
} else if (lookAhead(/\r?\n/)) {
debug("| newline, going to base");
buffer(/[^]/);
mode = "base";
} else {
debug("| single char");
buffer(/[^]/);
}
}
} else if (mode === "blockComment") {
debug(`| mode blockComment, modeParam:${modeParam}`);
// in blockComment mode, 'modeParam' is the amount of whitespace to skip after newlines
// we should always enter this branch at the beginning of a new comment line
if (isDone()) {
throw parseError('non terminated block comment');
} else {
skip(raw`${singlehws}{0,${modeParam}}`);
if (tryBuffer(raw`.*?(?=\*/)`)) {
debug("| last block comment line");
skip(raw`\*/(${hws}\r?\n)?`);
flush("blockComment");
mode = "base";
} else {
debug("| ongoing block comment line");
buffer(raw`.*\r?\n`);
}
}
} else {
throw parseError(`unknown mode: ${mode}`);
}
}
/* Load code highlighter, markdown parser */
const md = markdownIt({
typographer: true,
breaks: false,
html: true,
})
.use(require('@gerhobbelt/markdown-it-anchor'),{
slugify: s => {
return `${fileSlug}-${slugify(s)}`;
},
level: config.level,
callback: ({tag},{title,slug}) => {
toc.push({tag,title,slug});
}
})
.use(require('markdown-it-sup'))
.use(require('markdown-it-sub'))
.use(require('@iktakahiro/markdown-it-katex'));
/* Remove extra \r?\n in comments */
const trimPreNL = (s) => {
return s.replace(/^\r?\n*/, "");
};
/* Turn linear parsed blocks into structured content */
const freshPack = () => {
return {lineno: 0, lineComment: [], blockComment: [], code: []};
};
const packedResults = [freshPack()];
debug(results);
for (const result of results) {
let last = packedResults[packedResults.length - 1];
const c = result.type === "code" ? result.content : trimPreNL(result.content || "");
if (result.type === "code") {
if (last.closed) {
last = freshPack();
packedResults.push(last);
}
if (last.code.length === 0) {
last.lineno = result.lineno;
}
last["code"].push(result.content);
}
if (result.type === "clear") {
last.closed = true;
}
if (["lineComment","blockComment"].includes(result.type)) {
if (last.code.length > 0) {
last.closed = true;
}
last = freshPack();
packedResults.push(last);
last[result.type].push(trimPreNL(result.content));
}
}
/* Generate spans of lines (the left line gutter in the code panel) */
const makeLines = (from,code) => {
const lines = code.split('\n').map((_,i) => `${from+i}`).join('\n');
return lines;
};
/* Replace parsed input with rendered output */
for (const pack of packedResults) {
pack.blockComment = md.render(pack.blockComment.join("\n\n"));
pack.lineComment = md.render(pack.lineComment.join("\n\n"));
let joined = pack.code.join("");
if (joined.match(/\n$/)) {
joined = joined.slice(0,-1);
}
//Skip only-spaces
if (!joined.match(/^\s*$/)) {
pack.lines = makeLines(pack.lineno,joined);
pack.rawCode = joined;
pack.code = prism.highlight(
joined,
language.object,
language.name
).split('\r?\n').join('<br>');
} else {
pack.lines = '';
pack.code = '';
pack.rawCode = '';
}
}
packedFiles.push({fileSlug, file, packedResults});
}
debug(packedFiles); // depth null often useful
const util = require('util');
debug("packedFiles, %s",util.inspect(packedFiles,{depth:null}));
/* Compile with handlebars */
if (config.code) {
const template = `{{#each toc}}
{{title}}
{{/each}}
{{#each packedFiles}}
================
{{file}}
================
{{#each packedResults}}
{{{rawCode}}}
{{/each}}
{{/each}}`;
const newPackedFiles = packedFiles.map(({fileSlug,file,packedResults}) => {
const newPackedResults = packedResults.filter(({rawCode}) => { return rawCode !== ''; });
return {fileSlug,file,packedResults:newPackedResults};
});
const code = handlebars.compile(template)({toc,packedFiles: newPackedFiles, level: 0});
return code;
} else {
const template = fs.readFileSync(path.join(__dirname,'solcco.html'), "utf8");
const html = handlebars.compile(template)({toc, packedFiles, level: config.level});
return html;
}
}
module.exports = (_config={}) => {
const config = {
code: !!_config.code,
level: Number.isInteger(_config.level) ? _config.level : 2,
run(input) { return runner(this,input); }
}
return config;
}