heavy refactor and bug fixes

This commit is contained in:
Nathan George 2022-11-20 18:04:48 -05:00
parent 616f2eb316
commit cf2279f74a
14 changed files with 1508 additions and 1465 deletions

View file

@ -7,4 +7,4 @@ end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4
tab_width = 4

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -15,7 +15,7 @@ esbuild.build({
banner: {
js: banner,
},
entryPoints: ['main.ts'],
entryPoints: ['./scripts/main.ts'],
bundle: true,
external: [
'obsidian',

1243
main.ts

File diff suppressed because it is too large Load diff

View file

@ -26,8 +26,6 @@
"typescript": "4.7.4"
},
"dependencies": {
"file-saver": "^2.0.5",
"html2pdf.js": "^0.10.1",
"jquery": "^3.5.14",
"jszip": "^3.10.1"
}

677
scripts/html-gen.ts Normal file
View file

@ -0,0 +1,677 @@
import { writeFile } from "fs/promises";
import { MarkdownView, Notice, TextFileView } from "obsidian";
import { ExportSettings } from "./settings";
import { Utils } from "./utils";
import jQuery from 'jquery';
const $ = jQuery;
export class HTMLGenerator
{
// When this is enabled the plugin will download the extra .css and .js files from github.
autoDownloadExtras = true;
private vaultPluginsPath: string = Utils.getVaultPath() + "/.obsidian/plugins";
private thisPluginPath: string = this.vaultPluginsPath + "/obsidian-webpage-export";
private assetsPath: string = this.thisPluginPath + "/assets";
// this is a list of images that is populated during generation and then downloaded upon export
// I am sure there is a better way to handle this data flow but I am not sure what to do.
private imagesToDownload: { original_path: string, destination_path_rel: string }[] = [];
// this is a string containing the filtered app.css file. It is populated on load.
// The math styles are attempted to load on every export until they are succesfully loaded.
// This is because they only load when a file containing latex is opened.
appStyles: string = "";
// short html snippet for a dark mode toggle
darkModeToggle =
`\n\n
<div>
<label class="theme-toggle-inline" for="theme_toggle">
<input class="toggle__input" type="checkbox" id="theme_toggle">
<div class="toggle__fill"></div>
</label>
</div>
\n\n`
// the raw github urls for the extra files
private webpagejsURL: string = "https://raw.githubusercontent.com/KosmosisDire/obsidian-webpage-export/master/webpage.js";
private pluginStylesURL: string = "https://raw.githubusercontent.com/KosmosisDire/obsidian-webpage-export/master/plugin-styles.css";
private obsidianStylesURL: string = "https://raw.githubusercontent.com/KosmosisDire/obsidian-webpage-export/master/obsidian-styles.css";
private async downloadExtras()
{
//Download webpage.js
let webpagejs = await fetch(this.webpagejsURL);
let webpagejsText = await webpagejs.text();
await writeFile(this.assetsPath + "/webpage.js", webpagejsText).catch((err) => { console.log(err); });
//Download plugin-styles.css
let pluginStyles = await fetch(this.pluginStylesURL);
let pluginStylesText = await pluginStyles.text();
await writeFile(this.assetsPath + "/plugin-styles.css", pluginStylesText).catch((err) => { console.log(err); });
//Download obsidian-styles.css
let obsidianStyles = await fetch(this.obsidianStylesURL);
let obsidianStylesText = await obsidianStyles.text();
await writeFile(this.assetsPath + "/obsidian-styles.css", obsidianStylesText).catch((err) => { console.log(err); });
}
private async loadAppStyles()
{
var appSheet = document.styleSheets[1];
let stylesheets = document.styleSheets;
for (let i = 0; i < stylesheets.length; i++)
{
if (stylesheets[i].href && stylesheets[i].href?.includes("app.css"))
{
appSheet = stylesheets[i];
break;
}
}
this.appStyles += await Utils.getText(this.assetsPath + "/obsidian-styles.css");
for (var i = 0; i < appSheet.cssRules.length; i++)
{
var rule = appSheet.cssRules[i];
if (rule)
{
if (rule.cssText.startsWith("@font-face")) continue;
if (rule.cssText.startsWith(".CodeMirror")) continue;
if (rule.cssText.startsWith(".cm-")) continue;
this.appStyles += rule.cssText + "\n";
}
}
}
public async initialize()
{
await this.downloadExtras();
await this.loadAppStyles();
}
private async getThirdPartyPluginCSS() : Promise<string>
{
// load 3rd party plugin css
let pluginCSS = "";
let thirdPartyPluginStyleNames = ExportSettings.settings.includePluginCSS.split("\n");
for (let i = 0; i < thirdPartyPluginStyleNames.length; i++)
{
if (!thirdPartyPluginStyleNames[i] || (thirdPartyPluginStyleNames[i] && !(/\S/.test(thirdPartyPluginStyleNames[i])))) continue;
let path = this.vaultPluginsPath + "/" + thirdPartyPluginStyleNames[i].replace("\n", "") + "/styles.css";
let style = await Utils.getText(path);
if (style)
{
pluginCSS += style;
}
}
return pluginCSS;
}
async getSeperateFilesToDownload() : Promise<{filename: string, data: string, type: string, relativePath?: string, unicode?: boolean}[]>
{
var toDownload: {filename: string, data: string, type: string, relativePath?: string, unicode?: boolean}[] = [];
if (!ExportSettings.settings.inlineCSS)
{
let appcss = this.appStyles;
let plugincss = await Utils.getText(this.assetsPath + "/plugin-styles.css");
let themecss = await Utils.getThemeContent(Utils.getCurrentTheme());
let snippetsList = await Utils.getStyleSnippetsContent();
let snippetsNames = await Utils.getEnabledSnippets();
var snippets = "";
for (var i = 0; i < snippetsList.length; i++)
{
snippets += `/* --- ${snippetsNames[i]}.css --- */ \n ${snippetsList[i]} \n\n\n`;
}
let thirdPartyPluginCSS = await this.getThirdPartyPluginCSS();
plugincss += "\n" + thirdPartyPluginCSS + "\n";
let appcssDownload = { filename: "obsidian-styles.css", data: appcss, type: "text/css" };
let plugincssDownload = { filename: "plugin-styles.css", data: plugincss, type: "text/css" };
let themecssDownload = { filename: "theme.css", data: themecss, type: "text/css" };
let snippetsDownload = { filename: "snippets.css", data: snippets, type: "text/css" };
toDownload.push(appcssDownload);
toDownload.push(plugincssDownload);
toDownload.push(themecssDownload);
toDownload.push(snippetsDownload);
}
if (!ExportSettings.settings.inlineJS)
{
var webpagejs = await Utils.getText(this.assetsPath + "/webpage.js");
var webpagejsDownload = { filename: "webpage.js", data: webpagejs, type: "text/javascript" };
toDownload.push(webpagejsDownload);
}
// let imagesDownload : {path: string, data: string}[] = [];
if (!ExportSettings.settings.inlineImages)
{
for (var i = 0; i < this.imagesToDownload.length; i++)
{
var image = this.imagesToDownload[i];
var data = await Utils.getTextBase64(image.original_path);
var imageDownload =
{
filename: Utils.getFileNameFromFilePath(image.destination_path_rel),
data: data,
type: "image/png",
relativePath: Utils.getDirectoryFromFilePath(image.destination_path_rel),
unicode: false
};
toDownload.push(imageDownload);
}
}
return toDownload;
}
//#region General HTML
public async GetCurrentFileHTML(): Promise<string | null>
{
await Utils.delay(200);
let view = await Utils.getActiveView();
if (!view) return null;
Utils.setLineWidth(ExportSettings.settings.customLineWidth);
if (view instanceof MarkdownView)
await Utils.viewEnableFullRender(view);
var head = await this.generateHead(view);
var html = this.generateBodyHTML();
html = this.fixLinks(html);
html = this.repairOnClick(html); //replace data-onlick with onclick
if (ExportSettings.settings.inlineImages)
html = await this.inlineImages(html);
else
html = await this.outlineImages(html, view);
// inject darkmode toggle
if (ExportSettings.settings.addDarkModeToggle)
{
html = await this.injectToggle(html);
}
if (ExportSettings.settings.includeOutline)
{
var headers = this.getHeaderList(html);
if (headers)
{
var outline = this.generateOutline(headers);
// put side bars on either side of content and put them in a flex container
let el = document.createElement("html");
el.innerHTML = html;
let body = el.querySelector("body");
if (body)
{
html = `<div class="flex-container"><div id="sidebar" class="sidebar-left"></div>${body.innerHTML}<div id="sidebar" class="sidebar-right">${outline}</div></div>`;
body.innerHTML = html;
html = body?.outerHTML;
}
else
{
console.error("Could not find body element in html");
}
el.remove();
}
}
html = head + html;
// enclose in <html> tags
html = "<!DOCTYPE html>\n<html>\n" + html + "\n</html>";
return html;
}
private generateBodyHTML(): string
{
var bodyClasses = document.body.getAttribute("class") ?? "";
var bodyStyle = document.body.getAttribute("style") ?? "";
/*@ts-ignore*/
bodyClasses = bodyClasses.replaceAll("\"", "'");
/*@ts-ignore*/
bodyStyle = bodyStyle.replaceAll("\"", "'");
var htmlEl = (document.querySelector(".workspace-leaf.mod-active .markdown-reading-view") as HTMLElement)
if (!htmlEl) htmlEl = (document.querySelector(".workspace-leaf.mod-active .view-content") as HTMLElement);
let width = "1000px";
if (ExportSettings.settings.customLineWidth > 0)
width = ExportSettings.settings.customLineWidth + "px";
else
{
let sizer = (htmlEl.querySelector(".markdown-preview-sizer.markdown-preview-section") as HTMLElement);
if (sizer)
{
width = sizer.style.width;
}
}
htmlEl.style.flexBasis = width;
htmlEl.style.width = "unset";
var html = htmlEl.outerHTML;
html = "\n<body class=\"" + bodyClasses + "\" style=\"" + bodyStyle + "\">\n" + html + "\n</body>\n";
return html;
}
private getMathStyles(): string
{
let mathStyles = document.styleSheets[document.styleSheets.length - 1];
var mathStylesString = "";
var success = true;
for (var i = 0; i < mathStyles.cssRules.length; i++)
{
var rule = mathStyles.cssRules[i];
if (rule)
{
if (i == 0 && !rule.cssText.startsWith("mjx"))
{
success = false;
break;
}
if (rule.cssText.startsWith("@font-face")) continue;
mathStylesString += rule.cssText + "\n";
}
}
return success ? mathStylesString : "";
}
private async generateHead(view: TextFileView): Promise<string>
{
let meta =
`
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes">
<title>${view.file.basename}</title>
<link rel="icon" sizes="96x96" href="https://publish-01.obsidian.md/access/f786db9fac45774fa4f0d8112e232d67/favicon-96x96.png">
`
let mathStyles = this.getMathStyles();
let cssSettings = document.getElementById("css-settings-manager")?.innerHTML ?? "";
let scripts = "\n\n<script src='https://code.jquery.com/jquery-3.6.0.js'></script>"
+ ((ExportSettings.settings.inlineJS ? ("<script>\n" + await Utils.getText(this.assetsPath + "/webpage.js"))
: "<script src='webpage.js'></script>\n") + "\n</script>\n");
if (ExportSettings.settings.inlineCSS)
{
let pluginStyles = await Utils.getText(this.assetsPath + "/plugin-styles.css");
let snippets = await Utils.getStyleSnippetsContent();
let snippetNames = Utils.getEnabledSnippets();
let theme = await Utils.getThemeContent(Utils.getCurrentTheme());
let thirdPartyPluginStyles = await this.getThirdPartyPluginCSS();
pluginStyles += thirdPartyPluginStyles;
var header =
`
<head>
${meta}
<!-- Obsidian App Styles / Other Built-in Styles -->
<style> ${this.appStyles} </style>
<style> ${mathStyles} </style>
<style> ${cssSettings} </style>
<!-- Plugin Styles -->
<style> ${pluginStyles} </style>
<!-- Theme Styles ( ${Utils.getCurrentTheme()} ) -->
<style> ${theme} </style>
<!-- Snippets: ${snippetNames.join(", ")} -->
<style> ${snippets.join("</style><style>")} </style>
${scripts}
</head>
`;
}
else
{
header =
`
<head>
${meta}
<link rel="stylesheet" href="obsidian-styles.css">
<link rel="stylesheet" href="plugin-styles.css">
<link rel="stylesheet" href="theme.css">
<link rel="stylesheet" href="snippets.css">
<style> ${cssSettings} </style>
<style> ${mathStyles} </style>
${scripts}
</head>
`;
}
return header;
}
//#endregion
//#region Links and Images
private fixLinks(html: string): string
{
let el = document.createElement('html');
el.innerHTML = html;
let query = jQuery(el);
query.find("a.internal-link").each(function ()
{
$(this).attr("target", "_self");
let finalHref = "";
let href = $(this).attr("href")?.split("#");
if (!href) return;
// if the file doesn't start with #, then it links to a file, or a header in another file.
if (!(href[0] == ""))
{
if (href.length == 1)
{
finalHref = href[0] + ".html";
}
if (href.length == 2)
{
var filePath = "";
if (!href[0].contains("/") && !href[0].contains("\\"))
{
filePath = Utils.getDirectoryFromFilePath(Utils.getFirstFileByName(href[0])?.path ?? "") + "/";
}
finalHref = filePath + href[0] + ".html#" + href[1].replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
}
if (href.length > 2)
{
let first = href.shift() ?? "";
var filePath = "";
if (!first.contains("/") && !first.contains("\\"))
{
filePath = Utils.getDirectoryFromFilePath(Utils.getFirstFileByName(first)?.path ?? "") + "/";
}
finalHref = filePath + first + ".html#" + href.join("#").replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
}
}
else // if the file starts with #, then it links to an internal header.
{
href.shift();
if (href.length == 1)
{
finalHref = "#" + href[0].replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
}
if (href.length > 1)
{
finalHref = href.join("#").replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
}
}
$(this).attr("href", finalHref);
});
query.find("h1, h2, h3, h4, h5, h6").each(function ()
{
// use the headers inner text as the id
$(this).attr("id", $(this).text().replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_"));
});
let result = el.innerHTML;
el.remove();
return result;
}
private async inlineImages(html: string): Promise<string>
{
let el = document.createElement('html');
el.innerHTML = html;
let query = jQuery(el);
let images = query.find("img").toArray();
for (let i = 0; i < images.length; i++)
{
let img = images[i];
if ($(img).attr("src")?.startsWith("app://local/"))
{
let path = $(img).attr("src")?.replace("app://local/", "").replaceAll("%20", " ").split("?")[0];
if (path)
{
var base64 = "";
try
{
base64 = await Utils.getTextBase64(path);
}
catch (e)
{
console.error(e);
console.warn("Failed to inline image: " + path);
new Notice("Failed to inline image: " + path, 5000);
continue;
}
$(img).attr("src", "data:image/png;base64," + base64);
}
}
}
let result = el.innerHTML;
el.remove();
return result;
}
private async outlineImages(html: string, view: TextFileView): Promise<string>
{
let el = document.createElement('html');
el.innerHTML = html;
let query = jQuery(el);
this.imagesToDownload = [];
let img2Download: { original_path: string, destination_path_rel: string }[] = [];
let vaultPath = Utils.getVaultPath();
query.find("img").each(function ()
{
if (!$(this).attr("src")?.startsWith("app://local/")) return;
let originalPath = $(this).attr("src")?.replaceAll("\\", "/").replaceAll("%20", " ").replace("app://local/", "");
// console.log("originalPath: " + originalPath);
if (!originalPath)
{
new Notice("Failed to outline image: " + originalPath + ". Couldn't find image src", 5000);
return;
}
if (originalPath.startsWith("data:image/png;base64,") || originalPath.startsWith("data:image/jpeg;base64,")) return;
let relPath = originalPath.split(Utils.getDirectoryFromFilePath(vaultPath + "/" + view.file.path.replaceAll("\\", "/")) + "/")[1];
// console.log(originalPath, "vs.", Utils.getDirectoryFromFilePath(vaultPath + "/" + view.file.path.replaceAll("\\", "/")) + "/");
if (!relPath)
relPath = ("images/" + Utils.getFileNameFromFilePath($(this).attr("src")?.replaceAll("\\", "/").replaceAll("%20", " ") ?? "")) ?? "img.png";
// console.log("relPath: " + relPath);
$(this).attr("src", relPath);
img2Download.push({ original_path: originalPath, destination_path_rel: relPath });
});
this.imagesToDownload = img2Download;
let result = el.innerHTML;
el.remove();
return result;
}
//#endregion
//#region Special Features
private async injectToggle(html: string): Promise<string>
{
if (!html.contains(this.darkModeToggle.split("\n")[4]))
{
//insert fixed toggle in corner
console.log("Injecting toggle");
html = this.darkModeToggle.replace("theme-toggle-inline", "theme-toggle") + html;
}
return html;
}
private repairOnClick(html: string): string
{
html = html.replaceAll("data-onclick", "onclick");
return html;
}
private getHeaderList(html: string): { size: number, title: string, href: string }[] | null
{
var headers = [];
var el = document.createElement('html');
el.innerHTML = html;
var headerElements = el.querySelectorAll("h1, h2, h3, h4, h5, h6");
for (var i = 0; i < headerElements.length; i++)
{
var header = headerElements[i];
var size = parseInt(header.tagName[1]);
var title = (header as HTMLElement).innerText;
var href = (header as HTMLHeadingElement).id;
headers.push({ size, title, href });
}
el.remove();
return headers;
}
private generateOutline(headers: { size: number, title: string, href: string }[]): string
{
var outline =
`
<div class="outline-container" data-size="0">
<div class="outline-header">
<svg viewBox="0 0 100 100" class="bullet-list" width="18" height="18"><path fill="var(--h6-color)" stroke="var(--h6-color)" d="M16.4,16.4c-3.5,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4s6.4-2.9,6.4-6.4S19.9,16.4,16.4,16.4z M16.4,19.6 c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.2-3.2,3.2s-3.2-1.4-3.2-3.2C13.2,21,14.6,19.6,16.4,19.6z M29.2,21.2v3.2H90v-3.2H29.2z M16.4,43.6c-3.5,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4s6.4-2.9,6.4-6.4S19.9,43.6,16.4,43.6z M16.4,46.8c1.8,0,3.2,1.4,3.2,3.2 s-1.4,3.2-3.2,3.2s-3.2-1.4-3.2-3.2S14.6,46.8,16.4,46.8z M29.2,48.4v3.2H90v-3.2H29.2z M16.4,70.8c-3.5,0-6.4,2.9-6.4,6.4 c0,3.5,2.9,6.4,6.4,6.4s6.4-2.9,6.4-6.4C22.8,73.7,19.9,70.8,16.4,70.8z M16.4,74c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.2-3.2,3.2 s-3.2-1.4-3.2-3.2C13.2,75.4,14.6,74,16.4,74z M29.2,75.6v3.2H90v-3.2H29.2z"></path></svg>
<h6 style="margin: 1em"> Table of Contents </h6>
</div>
</div>
`;
var builderRoot = document.createElement('html');
builderRoot.innerHTML = outline;
var outlineEl = builderRoot.querySelector(".outline-container");
if (!outlineEl) return "";
var listItemTemplate =
`
<div class="outline-item" data-size="{size}">
<div class="outline-item-contents">
<div class="tree-item-icon collapse-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon right-triangle"><path d="M3 8L12 17L21 8"></path>
</svg>
</div>
<a class="outline-item-title" href="#{href}">{title}</a>
</div>
<div class="outline-item-children">
</div>
</div>
`;
var listStack = [outlineEl];
// function to get the data-size of the previous list item as a number
function getLastStackSize(): number
{
return parseInt(listStack[listStack.length - 1].getAttribute("data-size") ?? "0");
}
for (var i = 0; i < headers.length; i++)
{
var header = headers[i];
var listItem = listItemTemplate.replace("{size}", header.size.toString()).replace("{href}", header.href).replace("{title}", header.title);
while (getLastStackSize() >= header.size && listStack.length > 1)
{
listStack.pop();
}
var builditemRoot = document.createElement('div');
builditemRoot.innerHTML = listItem;
var newOutlineItem = builditemRoot.querySelector(".outline-item");
if (!newOutlineItem) continue;
var childContainer = listStack.last()?.querySelector(".outline-item-children");
if (getLastStackSize() == 0) childContainer = listStack.last();
if (!childContainer) continue;
childContainer.appendChild(newOutlineItem);
listStack.push(newOutlineItem);
builditemRoot.remove();
}
var result = builderRoot.innerHTML;
builderRoot.remove();
return result;
}
//#endregion
}

114
scripts/leaf-handler.ts Normal file
View file

@ -0,0 +1,114 @@
import { MarkdownView, OpenViewState, PaneType, SplitDirection, TFile, WorkspaceLeaf } from "obsidian";
export class LeafHandler
{
// https://github.com/darlal/obsidian-switcher-plus/blob/27d337039883008bcbf40ca13ea2f9287469dde4/src/Handlers/handler.ts#L388
// Some functions here are from this source and have been modified to fit the needs of this plugin.
isMainPanelLeaf(leaf: WorkspaceLeaf): boolean
{
const { workspace } = app;
const root = leaf?.getRoot();
/*@ts-ignore*/
return root === workspace.rootSplit || root === workspace.floatingSplit;
}
getOpenLeaves(excludeMainPanelViewTypes?: string[], includeSidePanelViewTypes?: string[]): WorkspaceLeaf[]
{
const leaves: WorkspaceLeaf[] = [];
const saveLeaf = (l: WorkspaceLeaf) =>
{
const viewType = l.view?.getViewType();
if (this.isMainPanelLeaf(l))
{
if (!excludeMainPanelViewTypes?.includes(viewType))
{
leaves.push(l);
}
} else if (includeSidePanelViewTypes?.includes(viewType))
{
leaves.push(l);
}
};
app.workspace.iterateAllLeaves(saveLeaf);
return leaves;
}
openFileInNewLeaf(
file: TFile,
navType: PaneType | boolean,
openState?: OpenViewState,
errorContext?: string,
splitDirection: SplitDirection = 'vertical',
): WorkspaceLeaf
{
const { workspace } = app;
errorContext = errorContext ?? '';
const message = `HTML Export: error opening file. ${errorContext}`;
const getLeaf = () =>
{
return navType === 'split'
? workspace.getLeaf(navType, splitDirection)
: workspace.getLeaf(navType);
};
var leaf = getLeaf();
try
{
leaf.openFile(file, openState).catch((reason) =>
{
console.log(message, reason);
});
}
catch (error)
{
console.log(message, error);
}
return leaf;
}
getLeafByFile(file: TFile): WorkspaceLeaf | null
{
const leaves = this.getOpenLeaves();
for (let leaf of leaves)
{
if (leaf.view instanceof MarkdownView)
{
if (leaf.view.file.path === file.path)
{
return leaf;
}
}
}
return null;
}
switchToLeafWithFile(file: TFile, openNewIfNotOpen: boolean): {leaf: WorkspaceLeaf | null, alreadyOpen: boolean}
{
const { workspace } = app;
let leaf = this.getLeafByFile(file);
let alreadyOpen = false;
if (leaf)
{
workspace.setActiveLeaf(leaf);
alreadyOpen = true;
}
else if (openNewIfNotOpen)
{
leaf = this.openFileInNewLeaf(file, true, { active: true }, "Failed to open file to new tab after it was found to not be open yet.");
}
return {leaf, alreadyOpen};
}
}

154
scripts/main.ts Normal file
View file

@ -0,0 +1,154 @@
// imports from obsidian API
import { Plugin, TAbstractFile, TFile} from 'obsidian';
// modules that are part of the plugin
import { ExportSettings } from './settings';
import { Utils } from './utils';
import { LeafHandler } from './leaf-handler';
import { HTMLGenerator } from './html-gen';
export default class HTMLExportPlugin extends Plugin
{
leafHandler: LeafHandler = new LeafHandler();
htmlGenerator: HTMLGenerator = new HTMLGenerator();
addTogglePostprocessor()
{
this.registerMarkdownCodeBlockProcessor("theme-toggle", (source, el, ctx) =>
{
let parent = el.createEl('div');
parent.innerHTML = this.htmlGenerator.darkModeToggle;
});
//also replace `theme-toggle` and ```theme-toggle``` for better inline toggles, or in places you couldn't use a normal code block
this.registerMarkdownPostProcessor((element, context) =>
{
let codeBlocks = element.querySelectorAll('code, span.cm-inline-code');
codeBlocks.forEach((codeBlock) =>
{
// console.log(codeBlock);
if (codeBlock instanceof HTMLElement && codeBlock.innerText == "theme-toggle")
{
codeBlock.outerHTML = this.htmlGenerator.darkModeToggle;
}
});
});
}
async onload()
{
console.log('loading obsidian-webpage-export plugin');
// init settings
new ExportSettings(this);
ExportSettings.loadSettings();
// Register the Export As HTML button in the file menu
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) =>
{
menu.addItem((item) =>
{
item
.setTitle("Export to HTML")
.setIcon("document")
.onClick(async () =>
{
if (Utils.getFileNameFromFilePath(file.path).contains("."))
this.exportFile(file);
else
this.exportFolder(file.path);
});
});
})
);
// add export vault icon to ribbon
this.addRibbonIcon("folder-up", "Export Vault to HTML", async () =>
{
this.exportFolder("");
});
// add toggle postprocessor
this.addTogglePostprocessor();
// init html generator
this.htmlGenerator.initialize();
}
onunload()
{
console.log('unloading obsidian-webpage-export plugin');
}
async exportFile(file: TAbstractFile, fullPath: string = "", showSettings: boolean = true)
{
// Open the settings modal and wait until it's closed
let onlyCopy = false;
if (showSettings)
{
let result = await new ExportSettings(this).open();
if (result.canceled) return;
onlyCopy = result.onlyCopy;
}
let fileTab = this.leafHandler.openFileInNewLeaf(file as TFile, true);
var html = await this.htmlGenerator.GetCurrentFileHTML();
if (!html) return;
let toDownload = await this.htmlGenerator.getSeperateFilesToDownload();
// Close the file tab after HTML is generated
fileTab.detach();
// if onlyCopy is true, then we don't want to download the file, we just want to copy it to the clipboard
if (onlyCopy)
{
navigator.clipboard.writeText(html);
return;
}
// Download files
var htmlDownload = { filename: file.name.replace(".md", ".html"), data: html, type: "text/html" };
toDownload.push(htmlDownload);
var htmlPath: string | null = fullPath;
if (htmlPath == "")
htmlPath = await Utils.showSaveDialog(Utils.idealDefaultPath(), file.name.replace(".md", ".html"), false);
if (!htmlPath) return;
let filename = Utils.getFileNameFromFilePath(htmlPath);
let folderPath = Utils.getDirectoryFromFilePath(htmlPath);
toDownload[toDownload.length - 1].filename = filename;
await Utils.downloadFiles(toDownload, folderPath);
}
async exportFolder(folderPath: string)
{
// folder path is the path relative to the vault that we are exporting
// Open the settings modal and wait until it's closed
var exportCanceled = !await new ExportSettings(this).open();
if (exportCanceled) return;
let htmlPath = await Utils.showSelectFolderDialog(Utils.idealDefaultPath());
var files = this.app.vault.getFiles();
for (var i = 0; i < files.length; i++)
{
var file = files[i];
if (file.path.startsWith(folderPath) && file.extension == "md")
{
var fullPath = htmlPath + "/" + file.path.replace(".md", ".html");
await this.exportFile(file, fullPath, false);
}
}
}
}

221
scripts/settings.ts Normal file
View file

@ -0,0 +1,221 @@
import { App, Modal, Plugin, Setting } from 'obsidian';
import { Utils } from './utils';
export interface ExportSettingsData
{
inlineCSS: boolean;
inlineJS: boolean;
inlineImages: boolean;
uzeZip: boolean;
addDarkModeToggle: boolean;
includeOutline: boolean;
customLineWidth: number;
lastExportPath: string;
customJS: string;
customCSS: string;
includePluginCSS: string;
}
const DEFAULT_SETTINGS: ExportSettingsData =
{
inlineCSS: true,
inlineJS: true,
inlineImages: true,
uzeZip: false,
addDarkModeToggle: true,
includeOutline: true,
customLineWidth: 0,
lastExportPath: '',
customJS: '',
customCSS: '',
includePluginCSS: ''
}
export class ExportSettings extends Modal
{
static settings: ExportSettingsData = DEFAULT_SETTINGS;
static plugin: Plugin;
static isClosed: boolean = true;
static success: boolean = false;
static onlyCopy: boolean = false;
constructor(plugin: Plugin)
{
super(plugin.app);
ExportSettings.plugin = plugin;
}
static async loadSettings()
{
ExportSettings.settings = Object.assign({}, DEFAULT_SETTINGS, await ExportSettings.plugin.loadData());
}
static async saveSettings()
{
await ExportSettings.plugin.saveData(ExportSettings.settings);
}
/**
* @brief Opens the modal and async blocks until the modal is closed.
* @returns True if the EXPORT button was pressed, false is the export was canceled.
* @override
*/
async open(): Promise<{canceled: boolean, onlyCopy: boolean}>
{
super.open();
ExportSettings.isClosed = false;
const { contentEl } = this;
contentEl.empty();
let header = contentEl.createEl('h2', { text: 'HTML Webpage Settings' });
header.style.display = 'inline-block';
let supporLink = contentEl.createEl('a', { href: 'https://www.buymeacoffee.com/nathangeorge' });
let supportButton = supporLink.createEl('img');
supportButton.setAttribute('src', 'https://cdn.buymeacoffee.com/buttons/v2/default-violet.png');
supportButton.setAttribute('style', ' height: 30px; margin-right: 0; margin-left: 17px; transform: translateY(25%);');
supportButton.setAttribute('alt', 'Buy Me A Coffee');
contentEl.createEl('h3', { text: 'Inlining Options:' });
new Setting(contentEl)
.setName('Inline CSS')
.setDesc('Inline the CSS into the HTML file.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.inlineCSS)
.onChange(async (value) =>
{
ExportSettings.settings.inlineCSS = value;
await ExportSettings.saveSettings();
}));
new Setting(contentEl)
.setName('Inline JS')
.setDesc('Inline the JS into the HTML file.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.inlineJS)
.onChange(async (value) =>
{
ExportSettings.settings.inlineJS = value;
await ExportSettings.saveSettings();
}));
new Setting(contentEl)
.setName('Inline Images')
.setDesc('Inline the images into the HTML file.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.inlineImages)
.onChange(async (value) =>
{
ExportSettings.settings.inlineImages = value;
await ExportSettings.saveSettings();
}));
contentEl.createEl('h3', { text: 'Special Features:' });
new Setting(contentEl)
.setName('Add Dark Mode Toggle')
.setDesc('Adds a fixed theme toggle to the top of any page that doesn\'t already have a toggle embedded with `theme-toggle`.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.addDarkModeToggle)
.onChange(async (value) =>
{
ExportSettings.settings.addDarkModeToggle = value;
await ExportSettings.saveSettings();
}));
new Setting(contentEl)
.setName('Include Document Outline')
.setDesc('Will include an interactive document outline tree on the right side of the document.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.includeOutline)
.onChange(async (value) =>
{
ExportSettings.settings.includeOutline = value;
await ExportSettings.saveSettings();
}
));
new Setting(contentEl)
.setName('Custom Line Width')
.setDesc('Will set the line width of the document to the specified value. If set to 0, will use whatever the current line width is.')
.addText((text) => text
.setValue(ExportSettings.settings.customLineWidth.toString())
.onChange(async (value) =>
{
ExportSettings.settings.customLineWidth = parseInt(value);
await ExportSettings.saveSettings();
}
));
contentEl.createEl('h3', { text: 'Export Options:' });
// new Setting(contentEl)
// .setName('Export to ZIP')
// .setDesc('Will export a .zip file rather than putting all the files loose in the chosen folder.')
// .addToggle((toggle) => toggle
// .setValue(ExportSettings.settings.uzeZip)
// .onChange(async (value) =>
// {
// ExportSettings.settings.uzeZip = value;
// await ExportSettings.saveSettings();
// }));
new Setting(contentEl)
.setName('Include Plugin CSS')
.setDesc('Will include the CSS from the plugins listed below. Please write out the plugin\'s ID / folder name exactly each on a new line.')
.addTextArea((text) => text
.setValue(ExportSettings.settings.includePluginCSS)
.onChange(async (value) =>
{
ExportSettings.settings.includePluginCSS = value;
await ExportSettings.saveSettings();
}
));
new Setting(contentEl)
.setName('Start Export')
.addButton((button) => button
.setButtonText('Export')
.onClick(async () =>
{
this.close();
ExportSettings.success = true;
}
));
new Setting(contentEl)
.setName('Export and Copy HTML')
.addButton((button) => button
.setButtonText('Copy HTML')
.onClick(async () =>
{
this.close();
ExportSettings.success = true;
ExportSettings.onlyCopy = true;
}
));
await Utils.waitUntil(() => ExportSettings.isClosed, 60 * 60 * 1000, 200);
return { canceled: !ExportSettings.success, onlyCopy: ExportSettings.onlyCopy };
}
onClose()
{
const { contentEl } = this;
contentEl.empty();
ExportSettings.isClosed = true;
ExportSettings.success = false;
ExportSettings.onlyCopy = false;
}
}

340
scripts/utils.ts Normal file
View file

@ -0,0 +1,340 @@
import { open, readFile, writeFile, existsSync, mkdirSync } from 'fs';
import { FileSystemAdapter, MarkdownView, TextFileView, TFile } from 'obsidian';
import { ExportSettings } from './settings';
var JSZip = require("jszip");
/* @ts-ignore */
const dialog: Electron.Dialog = require('electron').remote.dialog;
export class Utils
{
static async delay (ms: number)
{
return new Promise( resolve => setTimeout(resolve, ms) );
}
static async getText(path: string): Promise<string>
{
return new Promise((resolve, reject) => {
open(path, 'r', (err, fd) => {
if (err) {
reject(err);
} else {
readFile(fd, { encoding: 'utf8' }, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
}
});
});
}
static async getTextBase64(path: string): Promise<string>
{
return new Promise((resolve, reject) => {
open(path, 'r', (err, fd) => {
if (err) {
reject(err);
} else {
readFile(fd, { encoding: 'base64' }, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
}
});
});
}
static changeViewMode(view: MarkdownView, modeName: "preview" | "source")
{
/*@ts-ignore*/
const mode = view.modes[modeName];
/*@ts-ignore*/
mode && view.setMode(mode);
};
static createUnicodeArray(content: string) : Uint8Array
{
var charCode, byteArray = [];
// BE BOM
byteArray.push(254, 255);
// LE BOM
// byteArray.push(255, 254);
for (var i = 0; i < content.length; ++i) {
charCode = content.charCodeAt(i);
// BE Bytes
byteArray.push((charCode & 0xFF00) >>> 8);
byteArray.push(charCode & 0xFF);
// LE Bytes
// byteArray.push(charCode & 0xff);
// byteArray.push(charCode / 256 >>> 0);
}
return new Uint8Array(byteArray);
}
static async showSaveDialog(defaultPath: string, defaultFileName: string, showAllFilesOption: boolean = true): Promise<string | null>
{
let type = (defaultFileName.split(".").pop() ?? "txt");
var filters = [{
name: type.toUpperCase() + " Files",
extensions: [type]
}];
if (showAllFilesOption)
{
filters.push({
name: "All Files",
extensions: ["*"]
});
}
let picker = await dialog.showSaveDialog({
defaultPath: (defaultPath + "/" + defaultFileName).replaceAll("\\", "/").replaceAll("//", "/"),
filters: filters,
properties: ["showOverwriteConfirmation"]
})
if (picker.canceled) return null;
let path = picker.filePath ?? "";
if (path != "")
{
ExportSettings.settings.lastExportPath = path;
ExportSettings.saveSettings();
}
return path;
}
static async showSelectFolderDialog(defaultPath: string): Promise<string | null>
{
let picker = await dialog.showOpenDialog({
defaultPath: defaultPath,
properties: ["openDirectory"]
});
if (picker.canceled) return null;
let path = picker.filePaths[0] ?? "";
if (path != "")
{
ExportSettings.settings.lastExportPath = path;
ExportSettings.saveSettings();
}
return path;
}
static idealDefaultPath() : string
{
return ExportSettings.settings.lastExportPath == "" ? (Utils.getVaultPath() ?? "") : ExportSettings.settings.lastExportPath;
}
static async downloadFile(data: string, filename: string, path: string = "")
{
if (path == "")
{
path = await Utils.showSaveDialog(Utils.idealDefaultPath(), filename) ?? "";
if (path == "") return;
}
var array = Utils.createUnicodeArray(data);
writeFile(path, array, (err) => {
if (err) throw err;
console.log('The file has been saved!');
});
}
static async downloadFilesAsZip(files: {filename: string, data: string, type: string, relativePath?: string}[], zipFileName: string)
{
var blobs = files.map(file => new Blob([file.data], {type: file.type}));
var zip = new JSZip();
for (var i = 0; i < files.length; i++)
{
let path = ((files[i].relativePath ?? "") + "/" + files[i].filename).replaceAll("//", "/");
zip.file(path, blobs[i]);
}
var zipBlob = await zip.generateAsync({type: "uint8array"});
var path = await Utils.showSaveDialog(Utils.idealDefaultPath(), zipFileName, false) ?? "";
if (path == "") return;
writeFile(path, zipBlob, (err) => {
if (err) throw err;
console.log('The file has been saved!');
});
}
static async downloadFiles(files: {filename: string, data: string, type?: string, relativePath?: string, unicode?: boolean}[], folderPath: string)
{
for (var i = 0; i < files.length; i++)
{
var array = (files[i].unicode ?? true) ? Utils.createUnicodeArray(files[i].data) : Buffer.from(files[i].data, 'base64');
let path = (folderPath + "/" + (files[i].relativePath ?? "") + "/" + files[i].filename).replaceAll("\\", "/").replaceAll("//", "/").replaceAll("//", "/");
let dir = Utils.getDirectoryFromFilePath(path);
if (!existsSync(dir))
{
mkdirSync(dir, { recursive: true });
}
writeFile(path, array, (err) => {
if (err) throw err;
console.log('The file has been saved!');
});
}
}
static getDirectoryFromFilePath(path: string): string
{
var forwardIndex = path.lastIndexOf("/");
var backwardIndex = path.lastIndexOf("\\");
var index = forwardIndex > backwardIndex ? forwardIndex : backwardIndex;
if (index == -1) return "";
return path.substring(0, index);
}
static getFileNameFromFilePath(path: string): string
{
var forwardIndex = path.lastIndexOf("/");
var backwardIndex = path.lastIndexOf("\\");
var index = forwardIndex > backwardIndex ? forwardIndex : backwardIndex;
if (index == -1) return path;
return path.substring(index + 1);
}
static getVaultPath(): string | null
{
let adapter = app.vault.adapter;
if (adapter instanceof FileSystemAdapter) {
return adapter.getBasePath().replaceAll("\\", "/");
}
return null;
}
//async function that awaits until a condition is met
static async waitUntil(condition: () => boolean, timeout: number = 1000, interval: number = 100): Promise<void>
{
return new Promise((resolve, reject) => {
let timer = 0;
let intervalId = setInterval(() => {
if (condition()) {
clearInterval(intervalId);
resolve();
} else {
timer += interval;
if (timer >= timeout) {
clearInterval(intervalId);
reject();
}
}
}, interval);
});
}
static async getThemeContent(themeName: string): Promise<string>
{
let themePath = this.getVaultPath() + "/.obsidian/themes/" + themeName + "/theme.css";
if (!existsSync(themePath)) return "";
let themeContent = await Utils.getText(themePath);
return themeContent;
}
static getCurrentTheme(): string
{
/*@ts-ignore*/ // config does exist
return app.vault.config?.cssTheme ?? "Default";
}
static getEnabledSnippets(): string[]
{
/*@ts-ignore*/
return app.vault.config?.enabledCssSnippets ?? [];
}
static async getStyleSnippetsContent(): Promise<string[]>
{
let snippetContents : string[] = [];
let enabledSnippets = this.getEnabledSnippets();
for (var i = 0; i < enabledSnippets.length; i++)
{
snippetContents.push(await Utils.getText(Utils.getVaultPath() + "/.obsidian/snippets/" + enabledSnippets[i] + ".css"));
}
return snippetContents;
}
static async viewEnableFullRender(view: MarkdownView)
{
Utils.changeViewMode(view, "preview");
await this.delay(200);
/*@ts-ignore*/
view.previewMode.renderer.showAll = true;
/*@ts-ignore*/
await view.previewMode.renderer.unfoldAllHeadings();
await Utils.delay(300);
/*@ts-ignore*/
await view.previewMode.renderer.rerender();
}
static async getActiveView(): Promise<TextFileView | null>
{
let view = app.workspace.getActiveViewOfType(TextFileView);
if (!view)
{
console.log("Failed to find active view");
return null;
}
return view;
}
static getFirstFileByName(name: string): TFile | undefined
{
return app.vault.getFiles().find(file =>
{
if(!name) return false;
return file.basename == name;
});
}
static setLineWidth(width: number) : void
{
if (width != 0)
{
let sizers = document.getElementsByClassName("markdown-preview-sizer markdown-preview-section");
if (sizers.length > 0)
sizers[0].setAttribute("style", "max-width: " + width + "px");
}
}
}

View file

@ -1,218 +0,0 @@
import { App, Modal, Plugin, Setting} from 'obsidian';
import { Utils } from 'main';
export interface ExportSettingsData
{
inlineCSS: boolean;
inlineJS: boolean;
inlineImages: boolean;
uzeZip: boolean;
addDarkModeToggle: boolean;
includeOutline: boolean;
customLineWidth: number;
lastExportPath: string;
customJS: string;
customCSS: string;
includePluginCSS: string;
}
const DEFAULT_SETTINGS: ExportSettingsData =
{
inlineCSS: true,
inlineJS: true,
inlineImages: true,
uzeZip: false,
addDarkModeToggle: true,
includeOutline: true,
customLineWidth: 0,
lastExportPath: '',
customJS: '',
customCSS: '',
includePluginCSS: ''
}
export class ExportSettings extends Modal
{
static settings: ExportSettingsData = DEFAULT_SETTINGS;
static plugin: Plugin;
static isClosed: boolean = true;
static success: boolean = false;
constructor(plugin: Plugin)
{
super(plugin.app);
ExportSettings.plugin = plugin;
}
static async loadSettings()
{
ExportSettings.settings = Object.assign({}, DEFAULT_SETTINGS, await ExportSettings.plugin.loadData());
}
static async saveSettings()
{
await ExportSettings.plugin.saveData(ExportSettings.settings);
}
/**
* @brief Opens the modal and async blocks until the modal is closed.
* @returns True if the EXPORT button was pressed, false is the export was canceled.
* @override
*/
async open(): Promise<boolean>
{
super.open();
ExportSettings.isClosed = false;
const {contentEl} = this;
contentEl.empty();
let header = contentEl.createEl('h2', {text: 'HTML Webpage Settings'});
header.style.display = 'inline-block';
let supporLink = contentEl.createEl('a', {href: 'https://www.buymeacoffee.com/nathangeorge'});
let supportButton = supporLink.createEl('img');
supportButton.setAttribute('src', 'https://cdn.buymeacoffee.com/buttons/v2/default-violet.png');
supportButton.setAttribute('style', ' height: 30px; margin-right: 0; margin-left: 17px; transform: translateY(25%);');
supportButton.setAttribute('alt', 'Buy Me A Coffee');
contentEl.createEl('h3', {text: 'Inlining Options:'});
new Setting(contentEl)
.setName('Inline CSS')
.setDesc('Inline the CSS into the HTML file.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.inlineCSS)
.onChange(async (value) =>
{
ExportSettings.settings.inlineCSS = value;
await ExportSettings.saveSettings();
}));
new Setting(contentEl)
.setName('Inline JS')
.setDesc('Inline the JS into the HTML file.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.inlineJS)
.onChange(async (value) =>
{
ExportSettings.settings.inlineJS = value;
await ExportSettings.saveSettings();
}));
new Setting(contentEl)
.setName('Inline Images')
.setDesc('Inline the images into the HTML file.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.inlineImages)
.onChange(async (value) =>
{
ExportSettings.settings.inlineImages = value;
await ExportSettings.saveSettings();
}));
contentEl.createEl('h3', {text: 'Special Features:'});
new Setting(contentEl)
.setName('Add Dark Mode Toggle')
.setDesc('Adds a fixed theme toggle to the top of any page that doesn\'t already have a toggle embedded with `theme-toggle`.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.addDarkModeToggle)
.onChange(async (value) =>
{
ExportSettings.settings.addDarkModeToggle = value;
await ExportSettings.saveSettings();
}));
new Setting(contentEl)
.setName('Include Document Outline')
.setDesc('Will include an interactive document outline tree on the right side of the document.')
.addToggle((toggle) => toggle
.setValue(ExportSettings.settings.includeOutline)
.onChange(async (value) =>
{
ExportSettings.settings.includeOutline = value;
await ExportSettings.saveSettings();
}
));
new Setting(contentEl)
.setName('Custom Line Width')
.setDesc('Will set the line width of the document to the specified value. If set to 0, will use whatever the current line width is.')
.addText((text) => text
.setValue(ExportSettings.settings.customLineWidth.toString())
.onChange(async (value) =>
{
ExportSettings.settings.customLineWidth = parseInt(value);
await ExportSettings.saveSettings();
}
));
contentEl.createEl('h3', {text: 'Export Options:'});
// new Setting(contentEl)
// .setName('Export to ZIP')
// .setDesc('Will export a .zip file rather than putting all the files loose in the chosen folder.')
// .addToggle((toggle) => toggle
// .setValue(ExportSettings.settings.uzeZip)
// .onChange(async (value) =>
// {
// ExportSettings.settings.uzeZip = value;
// await ExportSettings.saveSettings();
// }));
new Setting(contentEl)
.setName('Include Plugin CSS')
.setDesc('Will include the CSS from the plugins listed below. Please write out the plugin\'s ID / folder name exactly each on a new line.')
.addTextArea((text) => text
.setValue(ExportSettings.settings.includePluginCSS)
.onChange(async (value) =>
{
ExportSettings.settings.includePluginCSS = value;
await ExportSettings.saveSettings();
}
));
new Setting(contentEl)
.setName('Start Export')
.addButton((button) => button
.setButtonText('Export')
.onClick(async () =>
{
this.close();
ExportSettings.success = true;
}
));
new Setting(contentEl)
.setName('Export and Copy HTML')
.addButton((button) => button
.setButtonText('Copy HTML')
.onClick(async () =>
{
this.close();
ExportSettings.success = true;
}
));
await Utils.waitUntil(() => ExportSettings.isClosed, 60 * 60 * 1000, 200);
return ExportSettings.success;
}
onClose()
{
const {contentEl} = this;
contentEl.empty();
ExportSettings.isClosed = true;
ExportSettings.success = false;
}
}