theophile
Version:
A templating module that transforms a web page into a (Powerpoint-like) presentation
401 lines (399 loc) • 12.9 kB
JavaScript
import { marked } from "/node_modules/marked/lib/marked.esm.js";
import hljs from './highlight/highlight.min.js';
/**
* Description
* @export
* @class Theophile
*/
export default class Theophile {
static async exec(root) {
console.trace("Theophile BEGIN");
await this.init(root);
await this.prepare();
await this.process();
await this.beforeMount();
await this.mount();
await this.afterMount();
await this.clean();
console.trace("Theophile END");
}
/**
* Description
* @param {string} [root="."]
* @returns Promise
* @memberof Theophile
*/
static async init(root = ".") {
if (this.loaded) {
return Promise.resolve();
}
this.loaded = true;
this._root = "";
this.root = root;
this.ready = false;
this.plugins = {};
await this.loadConfig();
this.linkPromises = Promise.all([
this.loadScripts(),
this.loadStyles(),
]);
return Promise.all(this.loadPlugins(["Template", "Reference", "Slide", "Toc"]));
}
static get root() {
return this._root;
}
static set root(val) {
if (val.match(/^[a-zA-Z0-9]+:\/\//)) {
return (this._root = new URL(val));
}
var result = new URL(location);
if (val[0] === "/") {
result.pathname = val.replace(/\/*$/, "/");
} else {
var path = result.pathname.split("/").slice(0, -1);
path.push(val);
result.pathname = path.join("/");
}
return (this._root = result);
}
static siteURL(url) {
if (url && url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*?:\/\//)) {
return new URL(url);
}
var result = new URL(this.root);
if (!url) return result;
if (url[0] === "/") {
result.pathname = url;
return result;
}
result.pathname += url;
return result;
}
static processCData() {
var html = document.body.innerHTML;
var count = 0;
const div = document.createElement("div");
while (true) {
var openingIdx = html.indexOf("[CDATA{[");
if (openingIdx < 0) break;
var closingIdx = html.indexOf("]}]");
if (closingIdx < 0) break;
count += 1;
var code = html.slice(openingIdx + 8, closingIdx);
div.innerText = code;
html = html.slice(0, openingIdx) + `<pre class="th-no-markdown">${div.innerHTML}</pre>` + html.slice(closingIdx + 3);
}
if (count === 0) return;
document.body.innerHTML = html;
}
static processMarkdown() {
marked.use({
mangle: false,
headerIds: false,
});
// console.log(document.body.innerHTML);
// document.body.innerHTML = marked.parse('<div>ok</div># quoi \n# <span>_Marked_</span> in browser\n\nRendered by **marked**.');
var node, walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
const changed = [];
while (node = walker.nextNode()) {
if (["STYLE", "SCRIPT", "OBJECT", "IFRAME", "SVG"].indexOf(node.parentElement.nodeName) >= 0) continue;
if (node.parentNode.closest(".th-no-markdown") !== null) continue;
const startingText0 = node.nodeValue.trimEnd().replace(/^(?:\r\n|\n\r|\r|\n)/, "").replace(/'/g, "'");
const startingText = startingText0.replace(/"/g, """);
const startingText1 = startingText.replace(/>/g, ">").replace(/</g, "<");
if (!startingText) continue;
let text = node.nodeValue.trimEnd().split(/\r\n|\n\r|\r|\n/);
let spaces = text.filter(line => line.length > 0).map(line => line.match(/^\s*/)[0].length);
let deindent = Math.min(...spaces);
text = text.map(line => line.slice(deindent)).join("\n");
text = marked.parse(text).trimEnd().replace(/(?:^<p>)|(?:<\/p>$)/g, "");
if (text.trim() === startingText.trim() || text.trim() === startingText0.trim() || text.trim() === startingText1.trim() || text.trim() === "") continue;
// console.log("DEBUGGING MARKDOWN : ", startingText, startingText0, startingText1, text);
var div = document.createElement("div");
div.innerHTML = text;
while (div.firstChild) {
node.parentElement.insertBefore(div.firstChild, node);
}
changed.push(node);
}
changed.forEach(node => {
node.remove();
});
}
static processHeadings() {
var headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6,[data-th-heading]"));
headings.forEach(heading => {
if (heading.hasAttribute("id")) {
return;
}
var str = heading.textContent;
str = this.normalizeString(str);
str = str.slice(0, 128);
if (!document.getElementById(str)) {
heading.setAttribute("id", str);
return;
}
var n = 2;
while (document.getElementById(str + "-" + n)) {
n += 1;
}
heading.setAttribute("id", str + "-" + n);
});
}
static normalizeString(str) {
var result = str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9_\.\-]/g, "_")
.replace(/_+/g, "_");
return result;
}
static async prepare() {
await this.linkPromises;
await new Promise(resolve => {
//TODO Check pertinence
if (document.readyState === "complete" || document.readyState === "interactive") return resolve();
window.addEventListener("DomContentLoaded", e => {
console.trace("DomContent Loaded in prepare");
this.ready = true;
resolve();
});
});
console.trace("Theophile ready");
const promises = Array.from(Object.values(this.plugins), plugin => plugin.prepare());
const data = await Promise.all(promises);
console.trace("Plugins ready");
return data;
}
static async process() {
this.processCData();
this.processMarkdown();
this.processHeadings();
console.trace("Theophile processed");
const promises = Array.from(Object.values(this.plugins), plugin =>
plugin.process()
);
const data = await Promise.all(promises);
console.trace("Plugins processed");
return data;
}
static async beforeMount() {
console.trace("Theophile before mount");
const promises = Array.from(Object.values(this.plugins), plugin =>
plugin.beforeMount()
);
const data = await Promise.all(promises);
console.trace("Plugins before mounte");
return data;
}
static async mount() {
console.trace("Theophile mounted");
const promises = Array.from(Object.values(this.plugins), plugin =>
plugin.mount()
);
const data = await Promise.all(promises);
console.trace("Plugins mounted");
return data;
}
static async afterMount() {
console.trace("Theophile after mount");
hljs.highlightAll();
const promises = Array.from(Object.values(this.plugins), plugin =>
plugin.afterMount()
);
const data = await Promise.all(promises);
const listings = Array.from(document.querySelectorAll("pre>code"));
listings.forEach(listing => {
const copyBtn = document.createElement("button");
copyBtn.classList.add("th-copy-btn");
copyBtn.textContent = "content_copy";
copyBtn.addEventListener("click", e => {
const range = document.createRange();
range.selectNode(listing);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand("copy");
window.getSelection().removeAllRanges();
});
listing.parentElement.insertBefore(copyBtn, listing);
});
console.trace("Plugins after mount");
return data;
}
static async clean() {
document.documentElement.style.opacity = 1;
console.trace("Theophile cleaned");
const promises = Array.from(Object.values(this.plugins), plugin =>
plugin.clean()
);
const data = await Promise.all(promises);
console.trace("Plugins cleaned");
return data;
}
static appURL(file) {
if (file && file.match(/^[a-zA-Z][a-zA-Z0-9+.-]*?:\/\//)) {
return file;
}
var url = new URL(import.meta.url);
var path = url.pathname.split("/").slice(0, -2);
if (file) {
path.push(file);
}
url.pathname = path.join("/");
return url;
}
static async loadScripts() {
// await this.loadScript("https://cdn.jsdelivr.net/npm/marked/marked.min.js");
var urls = [
// "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/highlight.min.js",
// "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/languages/javascript.min.js",
// "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/languages/css.min.js",
];
const data = await Promise.all(urls.map(url => this.loadScript(url)));
console.trace("Scripts loaded");
return data;
}
static async loadStyles() {
var urls = [
this.appURL("src/css/style.css"),
"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/styles/a11y-dark.min.css",
];
const data = await Promise.all(urls.map(url => this.loadLink(url)));
console.trace("Styles loaded");
return data;
}
static loadScript(url) {
return new Promise(resolve => {
const script = document.head.appendChild(document.createElement("script"));
script.setAttribute("src", url);
script.addEventListener("load", e => {
resolve(e.currentTarget);
});
});
}
static loadLink(url) {
return new Promise(resolve => {
const link = document.head.appendChild(document.createElement("link"));
link.setAttribute("rel", "stylesheet");
link.setAttribute("href", url);
link.addEventListener("load", e => {
resolve(e.currentTarget);
});
});
}
static loadPlugins(plugins) {
if (this.include) {
plugins = this.include.trim().split(/\s*,\s*/).map(name => name[0].toUpperCase() + name.slice(1).toLowerCase());
}
if (this.exclude) {
const exclude = this.exclude.trim().split(/\s*,\s*/).map(name => name[0].toUpperCase() + name.slice(1).toLowerCase());
plugins = plugins.filter(name => exclude.indexOf(name) < 0);
}
return plugins.map(async file => {
return this.loadPlugin(file);
});
}
static loadPlugin(name) {
return import(`./plugins/${name}/${name}.js`).then(obj => {
const plugin = obj.default;
console.trace(`Plugin ${plugin.name} loaded`);
this[plugin.name] = plugin;
this.plugins[plugin.name] = plugin;
return plugin.init(this);
});
}
static loadJson(url) {
return new Promise((resolve, reject) => {
var xhr = new XMLHttpRequest();
xhr.open("get", url);
xhr.responseType = "json";
xhr.addEventListener("load", e => {
if (e.target.status === 404) {
return reject(e.target.statusText);
}
return resolve(e.target.response);
});
xhr.addEventListener("error", e => {
reject(e.target);
});
xhr.onerror = function () {
console.error("XHR error " + xhr.status);
};
xhr.upload.onloadstart = function () {
console.log("onloadstart" + xhr.status);
};
xhr.upload.onloadend = function () {
console.log("onloadend" + xhr.status);
};
xhr.upload.onerror = function () {
console.log("error" + xhr.status);
};
try {
xhr.send(null);
} catch (err) {
reject(err);
}
});
}
static async loadConfig() {
var config;
try {
config = await this.loadJson(this.siteURL("theophile.json"));
} catch (err) {
console.warn("No config file found. Loading default config.");
config = await this.loadJson(this.appURL("defaults/theophile.json"));
}
this.loadDataSet(document.documentElement, config);
for (let property in config) {
this[property] = config[property];
}
return config;
}
static loadDataSet(element, to) {
var dataset = element.dataset;
to = to || {};
for (let property in dataset) {
if (property === "th") {
this.parseConfigString(dataset[property], to);
} else if (property.match(/^th[A-Z]/) !== null) {
this.setCompoundProperty(property.slice(2), dataset[property], to);
}
}
return to;
}
static setCompoundProperty(property, value, to) {
const properties = property.replace(/[A-Z]/g, x => "-" + x).replace(/^-/, "").toLowerCase().split("-");
// properties.push(property.match())
// [...property.matchAll(/^[a-z0-9]*|[A-Z][a-z0-9]*/g), ...property.matchAll(/-[a-z][a-zA-Z0-9]*/g)];
to = to || {};
let destination = to;
properties.slice(0, -1).forEach(p => {
if (!destination[p]) {
destination[p] = {};
}
destination = destination[p];
});
destination[properties.slice(-1)[0]] = this.parseConfigString(value, destination[properties.slice(-1)[0]]);
}
static parseConfigString(data, to) {
if (!data) return {};
if (data.indexOf(":") < 0)
return data;
to = to || {};
data.replace(/\s*;\s*$/, "").split(/;/).forEach(property => {
var parts = property.match(/\s*([a-zA-z_-][a-zA-z0-9_-]*)\s*:\s*(.*)\s*/);
if (parts) {
this.setCompoundProperty(parts[1], parts[2], to);
}
});
//TODO Normalize
// if (!to) return data;
// for (const property in data) {
// if (Object.hasOwnProperty.call(data, property)) {
// to[property] = data[property];
// }
// }
return to;
}
}