microjam
Version:
Minimalistic JAMStack authoring and publishing environment
696 lines (654 loc) • 25.4 kB
JavaScript
/**
* microjam extension.js (c) 2020 Stefan Goessner
* @author Stefan Goessner
* @license MIT License
* @link https://github.com/goessner/microjam
*/
;
const vscode = require('vscode'),
fs = require('fs'),
path = require('path');
//const { Console } = require('console');
/**
* Static Extension Object.
*/
const ext = {
/**
* Cached markdown-it object
*/
mdit: null,
/**
* Cached markdown-it-texmath object
*/
texmath: null,
/**
* Access microjam configuration keys in 'package.json'
* @method
* @returns {any}
*/
cfg(key) {
return vscode.workspace.getConfiguration('microjam')[key];
},
/**
* Show information message
* @method
*/
infoMsg(msg) {
vscode.window.showInformationMessage(`microjam: ${msg}`);
},
/**
* Show error message
* @method
*/
errMsg(msg) {
vscode.window.showErrorMessage(`microjam: ${msg}`);
},
/**
* Markdown content => Html
* @method
* @returns {string} Html
*/
toHtml(md, permalink) {
const formatHeading = (match,level,id,content) => {
permalink = (permalink===true) ? '#' : permalink;
id = id.replace(/(.+?)(\-[\d]+)?$/g, '$1');
return permalink
? `<h${level} id="${id}">${content} <a class="plnk" href="#${id}">${permalink}</a></h${level}>`
: `<h${level} id="${id}">${content}</h${level}>`;
}
const html = ext.mdit.render(md) // ... change / remove some vscode stuff ...
.replace(/\sclass=\"code-line\"/g,'')
.replace(/\sdata-line=\"[0-9]+\"/g,'')
.replace(/\sdata-href=\".+?\"/g,'')
.replace(/<h([1-6])\s+?id=\"(.+?)\">(.+?)<\/h[1-6]>/g, formatHeading)
return html;
},
/**
* Find base directory path from document uri.
* @method
* @param {string} - uri of markdown file.
* @returns {string} base directory path ('docs').
*/
baseOfValidRepo(uri) {
for (let base=path.parse(uri); base.root !== base.dir; base = path.parse(base.dir)) {
if ( base.base === 'docs' // might be enough as condition in future ... ?
&& fs.existsSync(path.resolve(base.dir,'./package.json'))
&& !!JSON.parse(fs.readFileSync(path.resolve(base.dir,'./package.json'),'utf8')).microjam) {
return path.resolve(base.dir,'./docs');
}
}
return false;
},
/**
* (Initially) validate repository.
* @method
* @param {string} basedir - absolute base directory path
*/
validateRepo(basedir) { // assume minimum-valid repo-directory ...
let uri;
if (!fs.existsSync(uri=path.resolve(basedir,'./pages.json')))
fs.writeFileSync(uri, '[]', 'utf8');
if (!fs.existsSync(uri=path.resolve(basedir,'./theme'))) {
fs.mkdirSync(uri);
fs.writeFileSync(path.resolve(uri,'./template.js'), ext.defaults.templates, 'utf8');
fs.writeFileSync(path.resolve(uri,'./styles.css'), ext.defaults.css, 'utf8');
}
if (!fs.existsSync(uri=path.resolve(basedir,'../.vscode')))
fs.mkdirSync(uri);
if (!fs.existsSync(uri=path.resolve(basedir,'../.vscode/settings.json')))
fs.writeFileSync(uri, ext.defaults.settings, 'utf8');
},
/**
* Separate Frontmatter section and content.
* Parse Frontmatter (strict JSON required).
* @param {string} mdpath - absolute markdown file path
* @param {string} basedir - absolute base directory path
* @param {string} text - page markdown text (including frontmatter)
* @returns {object} page object.
*/
pageStructure(mdpath, basedir, text) {
const page = { layout:"none",
title:"",
date:"",
description:"",
tags:[],
category:[],
math: false
};
if (text) {
const base = path.relative(path.parse(mdpath).dir, basedir); // (relative) path from `md`-file to base directory `docs` ... !
let frontmatter;
page.content = text.replace(/^\s*?[{\-]{3}([\s\S]+?)[}\-.]{3}\s*?/g, ($0,$1) => { frontmatter = $1; return '';});
page.uri = mdpath;
page.reluri = path.relative(basedir, mdpath).replace(/\\/g,'/').replace(/\.md$/,'');
page.base = base === '' ? '.' : base;
if (frontmatter) {
try {
frontmatter = JSON.parse(`{${frontmatter}}`);
Object.assign(page, frontmatter);
if (page.layout !== 'none') { // allow arbitrary layouts, except 'none' !
if (page.layout === 'article') {
page.content.replace(/#{2}\s[Aa]bstract\s*([^#]+?)\s*?#/g, ($0,$1) => { page.abstract = $1; return '';});
if (page.abstract)
page.abstract = ext.toHtml(page.abstract, false);
}
if (page.uses) {
for (const use of page.uses) {
const file = path.resolve(basedir,use.uri);
const content = fs.existsSync(file) && fs.readFileSync(file,'utf8');
if (content)
use.content = ext.toHtml(content.replace(/\{base\}/mg, page.base));
}
}
}
// else // do silently nothing ... !
if (!page.description && page.title)
page.description = page.title;
}
catch (err) {
const transform = (pos) => `in frontmatter: … ${frontmatter.substring(Math.max(0,pos-15),pos-1)}¿${frontmatter.substring(pos-1,Math.min(pos+15,frontmatter.length-1))} …`;
const errstr = err.message.replace(/in JSON at position (\d+)/, ($0,$1) => { return transform(+$1)});
ext.errMsg(errstr);
}
}
}
return page;
},
/**
* Extract page markdown content.
* @param {string} text - page text (including frontmatter)
* @returns {string} page markdown content (without frontmatter).
*/
pageContent(text) {
return text.replace(/^\s*?[{\-]{3}([\s\S]+?)[}\-.]{3}\s*?/g, '');
},
/**
* Extract headings from content.
* @param {string} text - page text (including frontmatter)
* @returns {array} headings as `{str,permalink,level}` objects.
*/
extractHeadings(text) {
const hds = [... text.matchAll(/([#]+)\s+?(.*)/g)];
const headings = [];
for (const h of hds) {
const level = h[1].length;
const str = h[2].trim();
const permalink = ext.mdit.render(h[0]).match(/.+?id=\"([^"]*?)\".*/)[1]
.replace(/-\d$/g, ''); // remove trailing hyphen and digit !
headings.push({str,permalink,level});
}
return headings;
},
/**
* Update 'pages.json'
* @method
* @param {string} pagesuri - uri of 'pages.json'
* @param {object} entry - frontmatter object of current entry.
* @param {string} basedir - absolute base directory path
*/
updatePages(pagesuri, entry, basedir, template) {
const pages = JSON.parse(fs.readFileSync(pagesuri,'utf8'));
let found = false, dirtyindex = false;
for (let i=0; i < pages.length; i++) {
if (!found && pages[i].uri === entry.uri) { // entry found in pages ...
pages.splice(i,1,entry); // ... so substitute ...
found = true;
dirtyindex = dirtyindex || entry.layout === 'article';
}
else if (!fs.existsSync(pages[i].uri)) { // ... so remove potentially existing *.html file.
const htmlpath = path.resolve(basedir,pages[i].reluri+'.html');
if (fs.existsSync(htmlpath))
fs.unlinkSync(htmlpath);
if (pages[i].layout === 'article') dirtyindex = true; // need to refresh index ...
pages.splice(i,1); // remove from pages ...
}
}
if (!found) { // is a new entry ...
pages.push(entry); // ... add it to pages.
dirtyindex = dirtyindex || entry.layout === 'article';
}
if (dirtyindex) { // providently refresh `index.html` ...
pages.forEach((ent) => {
if (ent.layout === 'index') {
ent.content = ext.pageContent(fs.readFileSync(ent.uri,'utf8'));
ent.articles = pages.filter((e) => e.layout === 'article');
ext.saveAsHtml(basedir, ent, template);
delete ent.articles;
delete ent.content;
}
});
}
fs.writeFileSync(pagesuri, JSON.stringify(pages), 'utf8');
},
/**
* Save Html file
* @method
* @param {string} basedir - absolute base directory path
* @param {object} page - page object
*/
saveAsHtml(basedir, page, template) { // safely imply existing 'docs' directory ...
page.content = ext.toHtml(page.content, page.permalink || ext.cfg('permalink'));
try {
const layout = page.layout || 'page';
const html = template[layout](page);
const outuri = path.resolve(basedir, page.reluri+'.html');
fs.writeFileSync(outuri, html, 'utf8');
if (ext.cfg('showSaveMessage')) ext.infoMsg(`Html saved to ${outuri}`);
} catch (e) {
ext.errMsg(`Saving '${page.reluri+'.html'}' failed: ${e.message}`);
}
},
/**
* Rebuild all files.
* Rebuild `*.html` and `pages.json` files in `docs` folder including subdirectories,
* as long as these also have the extension `md`.
* @param {string} basedir - absolute base directory path
* @method
*/
rebuildAll(basedir, template) {
const pages = ext.collectPages(basedir, basedir, []);
const indexPages = pages.filter(page => page.layout === 'index'); // far from different to be 'index.md' !!
const articles = indexPages.length > 0 ? pages.filter(page => page.layout === 'article') : false;
if (indexPages.length > 0) // assume it's only one for now ... !
indexPages[0].articles = articles;
// create / overwrite *.html files
for (const page of pages) {
ext.saveAsHtml(basedir, page, template);
delete page.content; // strip off page structure
delete page.articles;
if (page.uses)
for (const use of page.uses)
delete use.content;
}
// store stripped off pages array to 'pages.json'.
fs.writeFileSync(path.resolve(basedir,'./pages.json'), JSON.stringify(pages), 'utf8');
},
/**
* Collect pages.
* Starting in `docs` folder advancing to subdirectories `*.md`.
* @param {string} dir - current directory path
* @param {string} basedir - base directory path (`docs` folder).
* @method
*/
collectPages(dir, basedir, pages) {
const mdfiles = fs.readdirSync(dir)
.filter((uri) => /.*\.md$/.test(uri));
for (const mdfile of mdfiles) {
const mdpath = path.resolve(dir,mdfile);
const stat = fs.statSync(mdpath);
if (fs.statSync(mdpath).isDirectory())
ext.collectPages(mdpath, basedir, pages);
else {
const page = ext.pageStructure(mdpath, basedir, fs.readFileSync(mdpath,'utf8'));
if (page.layout !== 'none')
pages.push(page);
}
}
return pages;
},
/**
* Save Markdown document command handler.
* @method
* @param {object} arg - document or uri object ... depends from where it was invoked.
*/
saveAsHtmlCmd(arg) {
const doc = arg && arg.uri ? arg : vscode.window.activeTextEditor && vscode.window.activeTextEditor.document;
const dirty = arg && (arg.isDirty || arg.isDirty === undefined); // if unknown state imply 'dirty'
const mdpath = dirty && doc && doc.languageId === 'markdown' && doc.uri.fsPath;
const basedir = mdpath && ext.baseOfValidRepo(mdpath);
if (basedir) {
ext.validateRepo(basedir); // first *.md file save ... !
const page = ext.pageStructure(mdpath, basedir, doc.getText());
const pagesuri = path.resolve(basedir, './pages.json');
const tmplturi = path.resolve(basedir, './theme/template.js');
const template = require(tmplturi);
if (page.layout === 'index')
page.articles = JSON.parse(fs.readFileSync(pagesuri,'utf8'))
.filter((e) => e.layout === 'article');
//console.log(page)
if (page.layout !== 'none')
ext.saveAsHtml(basedir, page, template);
delete page.content;
delete page.articles;
if (page.uses)
for (const use of page.uses)
delete use.content;
ext.updatePages(pagesuri, page, basedir, template);
// see https://stackoverflow.com/questions/23685930/clearing-require-cache
delete require.cache[tmplturi]; // still need to refresh files twice after changes in 'template.js'
}
// else // no markdown file or markdown file does not belong to a valid repo ... return silently ...
},
/**
* Rebuild repository command.
* Remove `*.html` and `pages.json` files from `docs` folder.
* @method
*/
rebuildCmd() {
const doc = vscode.window.activeTextEditor && vscode.window.activeTextEditor.document;
const basedir = doc && doc.languageId === 'markdown' && ext.baseOfValidRepo(doc.uri.fsPath);
if (basedir) {
ext.validateRepo(basedir); // possibly first command invocation ... !
const tmplturi = path.resolve(basedir, './theme/template.js');
const template = require(tmplturi);
ext.rebuildAll(basedir, template);
delete require.cache[tmplturi]; // still need to refresh files twice after changes in 'template.js'
}
else
ext.errMsg(`${basedir} is not a valid 'microjam' repository.`);
},
insertTocCmd(arg) {
const doc = arg && arg.uri ? arg : vscode.window.activeTextEditor && vscode.window.activeTextEditor.document;
const headings = ext.extractHeadings(doc.getText());
let toc = '';
for (const h of headings)
toc += `${Array(h.level-1).fill(' ').join('')}- [${h.str}](#${h.permalink})\n`;
vscode.env.clipboard.writeText(toc);
vscode.commands.executeCommand('editor.action.insertSnippet', {snippet: "$CLIPBOARD"} );
},
insertNavCmd(arg) {
const doc = arg && arg.uri ? arg : vscode.window.activeTextEditor && vscode.window.activeTextEditor.document;
const uri = doc.uri.fsPath;
const basedir = doc && doc.languageId === 'markdown' && ext.baseOfValidRepo(uri);
const mdname = path.relative(basedir, uri);
const htmlname = mdname.replace(/\\/g,'/').replace(/\.md$/,'.html');
const headings = ext.extractHeadings(doc.getText());
let nav = '';
for (const h of headings)
nav += `${Array(h.level-1).fill(' ').join('')}- [${h.str}]({base}/${htmlname}#${h.permalink})\n`;
vscode.env.clipboard.writeText(nav);
ext.infoMsg(`Navigation list of '${mdname}' copied to clipboard.`);
}
}
// extension is activated ..
exports.activate = function activate(context) {
const mdplugins = vscode.workspace.getConfiguration('microjam')['markdownItPlugins'];
// `onWillSaveTextDocument` shows us actual 'isDirty' flag in contrast to `onDidSaveTextDocument`.
vscode.workspace.onWillSaveTextDocument((obj) => ext.saveAsHtmlCmd(obj.document));
context.subscriptions.push(vscode.commands.registerCommand('extension.saveAsHtml', ext.saveAsHtmlCmd));
context.subscriptions.push(vscode.commands.registerCommand('extension.rebuild', ext.rebuildCmd));
context.subscriptions.push(vscode.commands.registerCommand('extension.insertToc', ext.insertTocCmd));
context.subscriptions.push(vscode.commands.registerCommand('extension.insertNav', ext.insertNavCmd));
// switch math mode on/off on file level for performance reasons ...
// ... depending on `"math":true` in frontmatter section.
vscode.window.onDidChangeActiveTextEditor(function(editor) {
if (editor.document.languageId === 'markdown') {
if (/\"math\":\s*?true/m.test(editor.document.getText())) {
ext.mdit.block.ruler.enable(ext.texmath.blockRuleNames, true);
ext.mdit.inline.ruler.enable(ext.texmath.inlineRuleNames, true);
}
else {
ext.mdit.block.ruler.disable(ext.texmath.blockRuleNames, true);
ext.mdit.inline.ruler.disable(ext.texmath.inlineRuleNames, true);
}
}
})
ext.infoMsg('ready ...');
return {
extendMarkdownIt: function(md) {
// permanently load 'markdown-it-texmath' ... but disable math rules in standard mode for performance reasons.
ext.mdit = md;
ext.texmath = require('markdown-it-texmath');
ext.mdit.use(ext.texmath, { "engine": "katex", "delimiters": "dollars", "katexOptions": { "macros": {"\\RR": "\\mathbb{R}" } } } );
// ext.mdit.block.ruler.disable(ext.texmath.blockRuleNames, true);
// ext.mdit.inline.ruler.disable(ext.texmath.inlineRuleNames, true);
for (const key of Object.keys(mdplugins)) { // see user settings
if (key !== 'markdown-it-texmath')
md.use(require(key),JSON.parse(JSON.stringify(mdplugins[key]))); // simply `mdplugins[key]` alone does not work ... magic ?!
}
return ext.mdit;
}
}
}
// extension is deactivated ..
exports.deactivate = function deactivate() {};
ext.defaults = {
templates:
`module.exports = {
// page layout ... possibly used by other templates
page(data) {
return \`<!doctype html>
<html lang="\${data.lang||'en'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1,user-scalable=no">
<meta name="description" content="\${data.description || (data.title + ' - microjam page')}">
\${data.date ? \`<meta name="date" content="\${new Date(data.date).toString()}">\` : ''}
\${data.tags ? \`<meta name="keywords" content="\${data.tags.join()}">\` : ''}
<base href="\${'./'+data.reldir}">
<title>\${data.title}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.18.1/styles/github-gist.min.css">
\${data.math ? \`<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex/dist/katex.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/markdown-it-texmath/css/texmath.css">\` : ''}
<link rel="stylesheet" href="./theme/styles.css">
</head>
<body>
<header>
<a href="./index.html" class="left">My Site</a>
<a href="./about.html" class="right">About</a>
</header>
<main>
\${data.content}
</main>
<footer>
<span class="left">© My Site</span>
<span class="center">powered by μJAM & VSCode — hosted by GitHub</span>
<span class="right" onclick="document.documentElement.className = document.documentElement.className === 'theme-dark' ? 'theme-light' : 'theme-dark';">☼</span>
</footer>
</body>
</html>\`
},
// article layout ... // test necessary
article(data) {
const articleContent = \`<article>
\${data.content}
</article>\`;
return tmpl.page(data);
},
// index layout ...
index(data) {
data.content = \`<article>
\${data.content}
\${data.articles.sort((a,b)=> a.date < b.date ? 1 : -1) // sort decending ..
.map(tmpl.articleEntry).join('')}
</article>\`;
return tmpl.base(data);
},
// article entry layout ... used for article list in index template
articleEntry(article) {
return \`<hr>
\${tmpl.dateElement(article.date)}
<h3><a href="\${article.reluri+'.html'}">\${article.title}</a></h3>
\${article.abstract || article.description}\`;
},
// date element layout ...
dateElement(date) {
const d = new Date(date);
return \`<time datetime="\${d}">\${d.toString().substr(4,3)} \${d.getDate()}, \${d.getFullYear()}</time>\`;
}
}
`,
css: `@media screen {
:root {
--color-footer: #e2f3f3;
--bgcol-footer: #1f3939;
--color-code: #e2f3f3;
--bgcol-code: #1f3939;
--color-shade: #666;
--sidebar-width: 14rem;
}
.theme-dark {
--color-main: snow;
--bgcol-main: #3c6362;
--color-header: #3c6362;
--bgcol-header: #e2f3f3;
--color-link: #c6dddb;
--color-hover: #6f9999;
--color-plnk: #fffafa22;
}
/* .theme-light */
html {
--color-main: #1f3939;
--bgcol-main: snow;
--color-header: #e2f3f3;
--bgcol-header: #3c6362;
--color-link: #4b7776;
--color-hover: #87acac;
--color-plnk: #1f393922;
}
body {
margin: 1em 1em;
padding: 0;
background-color: #ddd;
font-size: 12pt;
font-family: Helvetica, Arial, sans-serif;
box-shadow: 0 0 0.5em var(--color-shade);
}
@media screen and (min-width: 768px) {
body {
max-width: 768px;
margin: 1em auto;
}
}
main, footer {
padding: 5px 1em;
}
main {
color: var(--color-main);
background-color: var(--bgcol-main);
word-wrap: break-word;
}
header {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center; /* vertical align */
padding: 5px 1em;
color: var(--color-header);
background-color: var(--bgcol-header);
}
footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; /* vertical align */
font-size: 0.8em;
color: var(--color-footer);
background-color: var(--bgcol-footer);
}
header > .left {
text-align: left;
font-size: 2em;
}
header > .right {
text-align: right;
}
footer > .left {
grid-column: 1;
text-align: left;
}
footer > .center {
grid-column: 2;
text-align: center;
}
footer > .right {
grid-column: 3;
text-align: right;
}
footer > .right:hover {
cursor: pointer;
color: var(--color-hover);
}
main a:link, main a:visited {
color: var(--color-link);
text-decoration: none;
}
main a:hover {
color: var(--color-hover);
text-decoration: none;
}
footer > a {
color: var(--color-footer);
}
header a:link, header a:visited {
color: var(--color-header);
text-decoration: none;
}
header a:hover {
color: var(--color-hover);
}
h1>a.plnk,h2>a.plnk,h3>a.plnk {
display: none;
text-decoration: none;
color: inherit;
}
h1:hover>a.plnk,h2:hover>a.plnk,h3:hover>a.plnk {
display: initial;
}
p, blockquote {
text-align: justify;
}
blockquote {
font-size: 11pt;
margin-top: 1em;
margin-bottom: 1em;
border-left: .25em solid var(--bgcol-header);
color: #666;
background-color: #f6f6f6;
padding: 0 0.5em;
}
table {
display: table;
width: auto;
margin-left: auto;
margin-right: auto;
border-collapse: collapse;
}
table th, table td {
padding: 0.2rem 0.5rem;
border-left: none;
border-right: none;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
}
figure {
margin: 0.5em auto;
}
figure > * {
display: block;
margin: 0 auto;
page-break-inside: avoid;
text-align: center;
}
figure img {
max-width: 100%;
}
figcaption {
font-size: 11pt;
margin-top: 0.5em;
}
/* code sections */
pre > code > code > div,
pre > code.code-line > div {
font-size: 10pt;
color: var(--color-code);
background-color: var(--bgcol-code);
border-radius: 5px;
padding: 0.5em;
white-space: pre-wrap;
}
kbd {
font-size: 10pt;
border-radius: 3px;
padding: 1px 2px 0;
margin: 0 2px;
color: #444;
border: 1px solid #999;
background-color: #eee;
}
time {
color: var(--color-link);
}
} /* end @media screen */`,
settings: `{
"explorer.sortOrder": "type"
}`
};