checkpoint: working markdown<=>html converter

This commit is contained in:
evilchili
2025-12-25 10:29:46 -08:00
parent c540073b66
commit f21bdbdb0c
20 changed files with 1274 additions and 10643 deletions
+14 -8
View File
@@ -1,20 +1,26 @@
{% extends "base.html" %}
{% block styles %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='editor/editor.css' ) }}">
{% endblock %}
{% block content %}
<div id='{% if user.can_write(page) %}editor{% else %}viewer{% endif %}' class='read-only'>
{{ page.body }}
</div>
<div id='content'>{{ page.body }}</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='editor/commonmark.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/turndown-plugin-gfm.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script>
<!-- for converting markdown to html -->
<script src="{{ url_for('static', filename='editor/purify.min.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/marked.umd.min.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/grung.js' ) }}"></script>
{% if user.can_write(page) %}
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/joplin-turndown-plugin-gfm.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/grung-editor.js' ) }}"></script>
{% endif %}
<script>
const wiki = new Grung{% if user.can_write(page) %}Editor{% endif %}({plugins: [MacroPlugin]});
wiki.view();
</script>
{% endblock %}
File diff suppressed because one or more lines are too long
@@ -1,343 +0,0 @@
class Editor {
#states = {
VIEW: 'view',
EDIT: 'edit',
WYSIWYG: 'wysiwyg'
}
commonmark = null;
turndown = null;
#cachedHTML = null;
#cachedMarkdown = null;
#state = null;
#changed = false;
constructor(settings) {
/*
* Create a new Editor instance.
*/
this.element = document.getElementById(settings.editorId || 'editor');
this.source = this.element.innerHTML;
this.commonmark = {
reader: new commonmark.Parser(),
writer: new commonmark.HtmlRenderer(),
};
this.turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
this.plugins = {};
settings.plugins.forEach(plugin => {
this.plugins[plugin.name] = new plugin({name: plugin.name, editor: this});
});
this.#bindEvents();
this.getHTML();
this.element.classList.add("loaded");
}
#bindEvents() {
this.element.addEventListener('keydown', (evt) => {
if (this.#state === this.#states.VIEW) {
return;
}
if (event.key === 'Enter') {
if (this.#state === this.#states.EDIT) {
evt.preventDefault();
this.insertAtCursor(document.createTextNode("\n"));
}
}
if (this.#cachedMarkdown != this.element.innerHTML) {
this.#changed = true;
this.#cachedMarkdown = this.element.innerHTML;
}
});
};
getState() {
return this.#state;
}
setState(newState) {
this.#state = newState;
Object.values(this.#states).forEach(state => {
if (state == newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
}
});
}
getHTML() {
/*
* Convert the markdown source to HTML.
*/
if (this.#changed || !this.#cachedHTML) {
var md = this.getMarkdown();
var parsed = this.commonmark.reader.parse(md);
var html = this.commonmark.writer.render(parsed);
Object.values(this.plugins).forEach(plugin => {
html = plugin.toHTML(html);
});
this.#cachedHTML = html;
}
return this.#cachedHTML;
}
getMarkdown() {
/*
* Return the current markdown.
*/
if (this.getState() === this.#states.EDIT) {
this.#cachedMarkdown = this.element.innerHTML;
} else if (this.getState() === this.#states.WYSIWYG) {
var md = this.element.innerHTML;
Object.values(this.plugins).forEach(plugin => {
md = plugin.toMarkdown(md);
});
this.#cachedMarkdown = this.turndown.turndown(md);
} else if (!this.#cachedMarkdown) {
this.#cachedMarkdown = this.source;
}
this.#cachedMarkdown = this.#cachedMarkdown.replaceAll(/^&gt;/mg, '>');
return this.#cachedMarkdown;
}
reset() {
/*
* Discard any unsaved edits and reset the editor to its initial state.
*/
this.#cachedHTML = null;
this.#cachedMarkdown = null;
this.view();
}
view() {
/*
* Convert the editor read-only mode and display the current HTML.
*/
if (this.getState() === this.#states.VIEW) {
return;
}
this.element.innerHTML = this.getHTML();
this.setState(this.#states.VIEW);
}
wysiwyg() {
/*
* Put the editor in WYSIWYG editing mode.
*/
if (this.getState() === this.#states.WYSIWYG) {
return;
}
this.#changed = false;
this.element.contentEditable = true;
this.element.innerHTML = this.getHTML();
this.setState(this.#states.WYSIWYG);
}
edit() {
/*
* Put the editor into source editing mode.
*/
if (this.#state === this.#states.EDIT) {
return;
}
this.#changed = false;
this.element.contentEditable = true;
this.element.innerHTML = this.getMarkdown();
this.setState(this.#states.EDIT);
}
insertAtCursor(node) {
var sel, range, html;
sel = window.getSelection();
range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
this.element.focus();
sel.removeAllRanges();
sel.addRange(range);
}
}
class EditorPlugin {
constructor(settings) {
this.name = settings.name;
this.editor = settings.editor;
};
cleanAttribute(attribute) {
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
}
toMarkdown(html) {
return html;
};
toHTML(md) {
return md;
};
};
class TablePlugin extends EditorPlugin {
constructor(settings) {
super(settings);
this.editor.turndown.use(turndownPluginGfm.tables);
this.pattern = /(?<contents>(?:\|[^|]+?)+\|[\s\n]*)(?:<|$)/gims;
this.dividerPattern = /\|\s*\-+\s*\|/;
}
toHTML(md) {
md.matchAll(this.pattern).forEach(matched => {
var html = '<table><thead>';
var cellTag = 'th';
matched.groups.contents.split("\n").forEach(line => {
if (this.dividerPattern.test(line)) {
html += "</thead><tbody>";
cellTag = 'td';
} else {
html += "<tr>";
var cells = line.split("|");
html += "<" + cellTag + ">" + cells.slice(1, cells.length - 1).join("</" + cellTag + "><" + cellTag + ">") + "</" + cellTag + ">";
html += "</tr>";
}
});
html += "</tbody></table>";
md = md.replaceAll(matched[1], html);
});
return md;
}
}
class MacroPlugin extends EditorPlugin {
macros = {
// image: {}
// toc {}
// widget {}
//
html: {
toHTML: (settings) => {
var html = settings.block.replaceAll("&gt;", ">").replaceAll("&lt;", "<");
return html;
},
toMarkdown: (node) => {
const utf8encoder = new TextEncoder();
return "{{html\n" + b64decode(node.dataset.block) + "\n}}";
}
},
user: {
toHTML: (settings) => {
return document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
},
},
npc: {
toHTML: (settings) => {
var name = camelCase(settings.keywords).join(" ");
var target = name.replaceAll(" ", "");
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
},
toMarkdown: (node) => {
return `{{npc ${node.firstChild.dataset.npcName}}}`;
},
},
spell: {
toHTML: (settings) => {
var name = camelCase(settings.keywords).join(" ");
var target = name.replaceAll(" ", "");
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
},
toMarkdown: (node) => {
return `{{spell ${node.firstChild.dataset.spellName}}}`;
},
},
}
constructor(settings) {
super(settings);
this.pattern = /(?<contents>{{(?<name>\w+)(?<keywords>(?:\s*\w+)*)(?<parameters>(?:\s+\w+="[^"]*")*?)(?<block>\b.*?)?}})/gms;
this.paramPattern = /\b(?<name>[^=]+)="(?<value>[^"]*?)"\b/gm;
var plugin = this;
this.editor.turndown.addRule('macros', {
filter: ['span'],
replacement: function (content, node, options) {
const parser = plugin.macros[node.getAttribute('data-macro-name')].toMarkdown;
if (parser) {
return parser(node);
}
var md = '{{' + node.getAttribute('data-macro-name');
for (var param in node.getAttributeNames()) {
var val = node.getAttribute(param);
if (val) {
md += val + (param == 'data-macro-name' ? " " : `="${node.getAttribute(param)}" `);
}
}
md += '}}';
return md;
},
});
}
toHTML(md) {
const utf8encoder = new TextEncoder();
var output = md;
output.matchAll(this.pattern).forEach(matched => {
var macroName = matched.groups.name;
var html = `<span class='macro' data-macro-name='${macroName}'`;
var settings = {
block: matched.groups.block,
keywords: matched.groups.keywords,
params: {},
}
matched.groups.parameters.matchAll(this.paramPattern).forEach(param => {
settings.params[param.groups.name] = param.groups.value;
html += ` data-param-${param.groups.name}="${param.groups.value}"`;
});
html += ` data-block="${b64encode(matched.groups.block)}"`;
html += '>';
html += this.macros[macroName].toHTML(settings);
html += '</span>';
output = output.replaceAll(matched[1], html);
});
return output;
}
}
function camelCase(words) {
var output = [];
words.trim().split(/\s+/g).forEach(word => {
var lcWord = word.toLowerCase();
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
});
return output;
}
function b64encode(input) {
const utf8encoder = new TextEncoder();
return utf8encoder.encode(input).toBase64();
}
function b64decode(input) {
const utf8encoder = new TextEncoder();
return utf8encoder.decode(input.fromBase64());
}
editor = new Editor({
plugins: [TablePlugin, MacroPlugin]
});
editor.view();
@@ -0,0 +1,103 @@
class GrungEditor extends Grung {
constructor(settings) {
/*
* Create a new Editor instance.
*/
super(settings);
this.states = {
VIEW: 'view',
EDIT: 'edit',
WYSIWYG: 'wysiwyg'
}
this.turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
this.turndown.use([turndownPluginGfm.gfm, turndownPluginGfm.tables]);
this.turndown.keep(['pre']);
this.#bindEvents();
this.plugins().forEach(plugin => { plugin.setEditable() });
}
#bindEvents() {
this.element.addEventListener('keydown', (evt) => {
if (this.state === this.states.VIEW) {
return;
}
/*
if (event.key === 'Enter') {
console.log(this.#state, this.#states.EDIT);
if (this.#state === this.#states.EDIT) {
evt.preventDefault();
this.insertAtCursor(document.createTextNode("\n"));
}
}
*/
if (this.cachedMarkdown != this.element.textContent) {
this.changed = true;
this.cachedMarkdown = this.element.textContent;
}
});
};
HtmlToMarkdown(html) {
return this.turndown.turndown(html);
}
getMarkdown() {
/*
* Return the current markdown.
*/
if (this.getState() === this.states.EDIT) {
this.cachedMarkdown = this.element.innerHTML.replaceAll(/<\/?div>/g, "\n").replaceAll('<br>', "");
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.HtmlToMarkdown(this.element.innerHTML);
} else if (!this.cachedMarkdown) {
this.cachedMarkdown = this.source;
}
return this.cachedMarkdown;
}
wysiwyg() {
/*
* Put the editor in WYSIWYG editing mode.
*/
if (this.getState() === this.states.WYSIWYG) {
return;
}
this.changed = false;
this.element.contentEditable = true;
this.element.innerHTML = this.getHTML();
this.setState(this.states.WYSIWYG);
}
edit() {
/*
* Put the editor into source editing mode.
*/
if (this.state === this.states.EDIT) {
return;
}
this.changed = false;
this.element.contentEditable = true;
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT);
}
insertAtCursor(node) {
var sel, range, html;
sel = window.getSelection();
range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
this.element.focus();
sel.removeAllRanges();
sel.addRange(range);
}
}
@@ -0,0 +1,422 @@
class Grung {
constructor(settings) {
/*
* Create a new Editor instance.
*/
this.element = document.getElementById(settings.editorId || 'content');
this.source = this.element.textContent;
this.marked = marked;
this.marked.use({
breaks: false,
gfm: true,
});
this.states = {
VIEW: 'view',
}
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null;
this.changed = false;
this.enabledPlugins = {};
settings.plugins.forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({name: plugin.name, editor: this});
});
this.getHTML();
this.element.classList.add("loaded");
}
plugins() {
return Object.values(this.enabledPlugins).sort((a, b) => { a.precedence < b.precedence });
}
getState() {
return this.state;
}
setState(newState) {
this.state = newState;
Object.values(this.states).forEach(state => {
if (state == newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
}
});
}
markdownToHTML(md) {
var html = this.marked.parse(md);
return html;
}
getHTML(string) {
/*
* Convert the markdown source to HTML.
*/
if (this.changed || !this.cachedHTML) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
return this.cachedHTML;
}
getMarkdown() {
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.source;
}
return this.cachedMarkdown;
}
reset() {
/*
* Discard any unsaved edits and reset the editor to its initial state.
*/
this.cachedHTML = null;
this.cachedMarkdown = null;
this.view();
}
view() {
/*
* Convert the editor read-only mode and display the current HTML.
*/
if (this.getState() === this.states.VIEW) {
return;
}
this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW);
this.contentEditable = false;
}
}
class GrungPlugin {
constructor(settings) {
this.name = settings.name;
this.editor = settings.editor;
this.precedence = 50;
};
setEditable() {
};
toMarkdown(html) {
return html;
};
toHTML(md) {
return md;
};
};
class MacroPlugin extends GrungPlugin {
macros = {
// image: {}
// toc {}
// widget {}
//
style: {
inline: false,
toHTML: (token, node) => {
return node.replace(/class="macro"/, `class="macro ${token.keywords}"`);
}
},
multiline: {
inline: false,
fromHTML: (node, markdown) => {
var content = node.innerHTML.replaceAll("<br>", "\0");
content = content.replaceAll("{{{", "&#123;&#123;&#123;");
content = content.replaceAll("}}}", "&#125;&#125;&#125;");
content = encodeHtmlEntities(content);
return `{{{${content}}}}`;
},
},
user: {
inline: true,
toHTML: (token, node) => {
return node + document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
},
},
toc: {
inline: true,
element: 'div',
toHTML: (token, node) => {
return node + "</div>";
},
postprocess: (html) => {
const subList = (depth) => {
var li = document.createElement("li");
var ul = document.createElement("ul");
li.appendChild(ul);
return li;
};
const buf = document.createElement('div');
buf.innerHTML = html;
const tocElement =
buf.querySelectorAll('[data-macro-name="toc"]').forEach(tocElement => {
var params = {
depth: tocElement.dataset.paramDepth || 3,
keywords: tocElement.dataset.paramKeywords || "",
};
const headings = buf.querySelectorAll("h2, h3, h4, h5, h6");
const toc = document.createElement("ul");
toc.setAttribute('role', 'list');
var lastDepth = null;
var ul = toc;
headings.forEach(heading => {
var depth = parseInt(heading.nodeName[1]) - 1;
if (depth > params.depth) {
return;
}
if (lastDepth === null) {
lastDepth = depth;
}
var index = document.createElement("li");
index.innerHTML = '<a href="#h">' + heading.innerHTML + "</a>";
var list = null;
if (depth > lastDepth) {
list = subList(depth);
ul.appendChild(list);
ul = list.firstChild;
} else if (depth < lastDepth) {
var list = subList(depth);
toc.appendChild(list);
ul = list.firstChild;
}
ul.appendChild(index);
lastDepth = depth;
});
if (params.keywords) {
params.keywords.split(" ").forEach(className => {
if (className) {
toc.classList.add(className);
}
});
}
tocElement.appendChild(toc);
});
return buf.innerHTML;
},
},
npc: {
toHTML: (settings) => {
var name = camelCase(settings.keywords).join(" ");
var target = name.replaceAll(" ", "");
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
},
fromHTML: (node) => {
return `{{npc ${node.firstChild.dataset.npcName}}}`;
},
},
spell: {
toHTML: (settings) => {
var name = camelCase(settings.keywords).join(" ");
var target = name.replaceAll(" ", "");
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
},
fromHTML: (node) => {
return `{{spell ${node.firstChild.dataset.spellName}}}`;
},
},
}
getTokens = (pattern, source) => {
const matched = source.matchAll(pattern);
const tokens = [];
if (!matched) {
return tokens;
}
matched.forEach(match => {
if (!this.macros[match.groups.name]) {
return;
}
const token = {
type: 'macro',
source: match[0],
matched: match,
macro: this.macros[match.groups.name],
keywords: (match.groups.keywords || '').trim(),
inline: this.macros[match.groups.name].inline,
params: {},
rendered: '',
};
if (match.groups.parameters) {
var params = decodeHtmlEntities(match.groups.parameters.trim());
params.matchAll(this.paramPattern).forEach(param => {
if (param.groups) {
var name = param.groups.name;
token.params[name] = decodeHtmlEntities(param.groups.value.trim());
}
});
}
token.rendered = this.renderToken(token);
tokens.push(token);
});
return tokens;
}
renderToken = (token) => {
const tag = token.macro.element || (token.inline ? 'span' : 'div');
var node = `<${tag} class="macro" data-plugin-name="macro" data-macro-name="${token.matched.groups.name}"`;
for (var name in token.params) {
node += ` data-param-${name}="${(token.params[name] || "").trim()}"`;
}
if (token.keywords) {
node += ` data-keywords="${token.keywords}"`;
}
node += ` data-inline="${token.inline}"`;
node += ">";
if (token.macro.toHTML) {
node = token.macro.toHTML(token, node);
}
if (token.inline) {
node += `</${tag}>`;
}
// preserve the wrapping element, unless it is a paragraph.
if (token.matched.groups.wrap) {
if (token.matched.groups.wrap != '<p>') {
node = token.matched.groups.wrap + node + token.matched.groups.endwrap;
}
}
return node;
}
constructor(settings) {
super(settings);
this.pattern = /(?<!`)(?<wrap><[^>]+?>){{(?<name>\w+)(?<keywords>(?:\s*[\w-]+)*?)?(?<parameters>(?:\s+[\w-]+=\S+?)*)?\s*(?<closed>}})?(?<endwrap><\/[^>]+?>)/mg;
this.endPattern = /<p>}}\s*<\/p>/mg;
this.paramPattern = /\s*(?<name>[^=]+)="(?<value>[^"]*)"/g;
this.multilinePattern = /(?<!\{{3}[\s\n]*)\{{3}[\s\n]*(?<content>(.(?!\}{3})*)+?)[\s\n]*\}{3}/smg;
const plugin = this;
this.editor.marked.use({
hooks: {
preprocess: (source) => {
const matched = source.matchAll(plugin.multilinePattern);
var md = source;
matched.forEach(match => {
var wrapper = '<span class="macro" data-plugin-name="macro" data-macro-name="multiline" data-inline="true" style="display: inline-block;">';
var content = decodeHtmlEntities(match.groups.content)
.replaceAll("\n", "::BR::")
.replaceAll('`', '::QU::')
.replaceAll('{', '::OC::')
.replaceAll('}', '::CC::');
md = md.replaceAll(match[0], wrapper + '\0' + content + '\0</span>');
});
return md;
},
postprocess: (html) => {
plugin.getTokens(plugin.pattern, html).forEach(token => {
html = html.replaceAll(token.source, token.rendered);
});
html = html.replaceAll(plugin.endPattern, '</div>');
Object.values(plugin.macros).forEach(macro => {
if (macro.postprocess) {
html = macro.postprocess(html);
}
});
html = html.replaceAll("::BR::", "<br>")
.replaceAll('::QU::', '`')
.replaceAll('::OC::', '{')
.replaceAll('::CC::', '}');
// remove unsafe html tags
return DOMPurify.sanitize(html, {});
}
},
});
}
setEditable() {
this.editor.turndown.addRule('macros', {
filter: function (node, options) {
return ((node.nodeName === 'DIV' || node.nodeName === 'SPAN') && node.dataset.pluginName == 'macro')
},
replacement: function (content, node, options) {
var macro = plugin.macros[node.getAttribute('data-macro-name')];
if (macro.fromHTML) {
return macro.fromHTML(node, content);
}
var md = '{{' + node.dataset.macroName;
if (node.dataset.keywords) {
md += " " + node.dataset.keywords;
}
for (var paramName in node.dataset) {
if (paramName.indexOf("param") != 0) {
continue;
}
md += ` ${paramName.replace('param', '').toLowerCase()}="${node.dataset[paramName]}"`
};
if (node.dataset.inline == "false") {
md = `\n\n${md}\n\n`;
md += plugin.editor.HtmlToMarkdown(node.innerHTML);
md += "\n\n}}\n\n";
} else {
md += "}}";
}
// replace nulls with line breaks, for the multiline macro
md = md.replaceAll('\0', "\n");
return md;
},
});
}
}
function camelCase(words) {
var output = [];
words.trim().split(/\s+/g).forEach(word => {
var lcWord = word.toLowerCase();
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
});
return output;
}
function b64encode(input) {
return new TextEncoder().encode(input).toBase64();
}
function b64decode(input) {
return new TextDecoder().decode(Uint8Array.fromBase64(input));
}
function decodeHtmlEntities(html) {
var txt = document.createElement("textarea");
txt.innerHTML = html;
return txt.value;
}
function encodeHtmlEntities(str) {
return str.replace(/[\u00A0-\u9999<>\&]/g, i => '&#'+i.charCodeAt(0)+';')
}
@@ -43,6 +43,7 @@ var rules = {};
rules.tableCell = {
filter: ['th', 'td'],
replacement: function (content, node) {
if (tableShouldBeSkipped(nodeParentTable(node))) return content;
return cell(content, node)
}
};
@@ -50,19 +51,26 @@ rules.tableCell = {
rules.tableRow = {
filter: 'tr',
replacement: function (content, node) {
const parentTable = nodeParentTable(node);
if (tableShouldBeSkipped(parentTable)) return content;
var borderCells = '';
var alignMap = { left: ':--', right: '--:', center: ':-:' };
if (isHeadingRow(node)) {
for (var i = 0; i < node.childNodes.length; i++) {
const colCount = tableColCount(parentTable);
for (var i = 0; i < colCount; i++) {
const childNode = colCount >= node.childNodes.length ? null : node.childNodes[i];
var border = '---';
var align = (
node.childNodes[i].getAttribute('align') || ''
).toLowerCase();
var align = childNode ? (childNode.getAttribute('align') || '').toLowerCase() : '';
if (align) border = alignMap[align] || border;
borderCells += cell(border, node.childNodes[i]);
if (childNode) {
borderCells += cell(border, node.childNodes[i]);
} else {
borderCells += cell(border, null, i);
}
}
}
return '\n' + content + (borderCells ? '\n' + borderCells : '')
@@ -73,13 +81,27 @@ rules.table = {
// Only convert tables with a heading row.
// Tables with no heading row are kept using `keep` (see below).
filter: function (node) {
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
return node.nodeName === 'TABLE'
},
replacement: function (content) {
replacement: function (content, node) {
if (tableShouldBeSkipped(node)) return content;
// Ensure there are no blank lines
content = content.replace('\n\n', '\n');
return '\n\n' + content + '\n\n'
content = content.replace(/\n+/g, '\n');
// If table has no heading, add an empty one so as to get a valid Markdown table
var secondLine = content.trim().split('\n');
if (secondLine.length >= 2) secondLine = secondLine[1];
var secondLineIsDivider = secondLine.indexOf('| ---') === 0;
var columnCount = tableColCount(node);
var emptyHeader = '';
if (columnCount && !secondLineIsDivider) {
emptyHeader = '|' + ' |'.repeat(columnCount) + '\n' + '|' + ' --- |'.repeat(columnCount);
}
return '\n\n' + emptyHeader + content + '\n\n'
}
};
@@ -120,16 +142,71 @@ function isFirstTbody (element) {
)
}
function cell (content, node) {
var index = indexOf.call(node.parentNode.childNodes, node);
function cell (content, node = null, index = null) {
if (index === null) index = indexOf.call(node.parentNode.childNodes, node);
var prefix = ' ';
if (index === 0) prefix = '| ';
return prefix + content + ' |'
let filteredContent = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, "<br>");
filteredContent = filteredContent.replace(/\|+/g, '\\|');
while (filteredContent.length < 3) filteredContent += ' ';
if (node) filteredContent = handleColSpan(filteredContent, node, ' ');
return prefix + filteredContent + ' |'
}
function nodeContainsTable(node) {
if (!node.childNodes) return false;
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child.nodeName === 'TABLE') return true;
if (nodeContainsTable(child)) return true;
}
return false;
}
// Various conditions under which a table should be skipped - i.e. each cell
// will be rendered one after the other as if they were paragraphs.
function tableShouldBeSkipped(tableNode) {
if (!tableNode) return true;
if (!tableNode.rows) return true;
if (tableNode.rows.length === 1 && tableNode.rows[0].childNodes.length <= 1) return true; // Table with only one cell
// Not sure why we're excluding this. possibly because it'll freak out the parser? --evilchili
//if (nodeContainsTable(tableNode)) return true;
return false;
}
function nodeParentTable(node) {
let parent = node.parentNode;
while (parent.nodeName !== 'TABLE') {
parent = parent.parentNode;
if (!parent) return null;
}
return parent;
}
function handleColSpan(content, node, emptyChar) {
const colspan = node.getAttribute('colspan') || 1;
for (let i = 1; i < colspan; i++) {
content += ' | ' + emptyChar.repeat(3);
}
return content
}
function tableColCount(node) {
let maxColCount = 0;
for (let i = 0; i < node.rows.length; i++) {
const row = node.rows[i];
const colCount = row.childNodes.length;
if (colCount > maxColCount) maxColCount = colCount;
}
return maxColCount
}
function tables (turndownService) {
turndownService.keep(function (node) {
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
return node.nodeName === 'TABLE'
});
for (var key in rules) turndownService.addRule(key, rules[key]);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+146
View File
@@ -113,6 +113,7 @@ table {
th {
border-bottom: 1px solid #000;
padding: 3px;
}
th, td {
padding: 2px;
@@ -206,3 +207,148 @@ footer {
menu {
display: none;
}
.left {
display: block;
float: left;
margin:5px;
max-width: 30%;
}
.right {
display: block;
float: right;
margin: 5px;
max-width: 30%;
}
.center {
width: fit-content;
margin: 0 auto;
}
.box {
border-radius: 5px;
border: 1px solid black;
background: #DEDEDE;
padding: 10px;
}
.striped > table {
background: #FAFAFA;
}
.striped > table tr:nth-child(even) {
background-color: #FFFFFF;
}
.equal-widths > table {
table-layout: fixed;
width: 100%;
}
.layout > table {
border: none;
background: transparent;
}
.layout > table > thead {
display: none;
}
table td table {
max-width:95%;
}
pre {
border: 1px dashed black;
border-radius: 5px;
padding: 10px;
margin: 5px;
background: #EEE;
}
code {
display: inline-block;
border: 1px dashed black;
border-radius: 5px;
padding: 5px;
background: #EEE;
margin: 3px;
}
div.macro {
display: inline;
}
div[data-macro-name="toc"] {
display: inline;
float: left;
border-radius: 5px;
font-size: 14px;
margin-right: 2em;
margin-bottom: 2em;
border: 1px solid #000;
padding: 0ch 2ch;
}
div[data-macro-name="toc"] ul {
box-sizing: border-box;
list-style: none;
padding-left: 2ch;
font-weight: normal;
margin-left: 0px;
}
div[data-macro-name="toc"] > ul:first-child {
padding-left: 0ch;
margin-left: 0px;
}
div[data-macro-name="toc"] > ul:first-child > li {
padding-left: 0ch;
margin-left: 0px;
}
div[data-macro-name="toc"] > ul:first-child > li {
font-weight: bold;
}
div[data-macro-name="toc"] > ul > li {
font-weight: bold;
}
div[data-macro-name="toc"] > ul > li {
padding-left: 2ch;
}
div[data-macro-name="toc"] a {
display: block;
width: 100%;
}
div[data-macro-name="toc"] a:hover {
background: #CCC;
}
#content {
display: none;
}
#content.loaded {
display: block;
}
#content.wysiwyg {
display: none,
}
#content.view {
}
#content.edit {
font-family: monospace;
white-space: pre;
}
#content.wysiwyg {
}
#content.wysiwyg .md {
opacity: 0.5;
}
+112
View File
@@ -0,0 +1,112 @@
APIv1 = {
get: function(doc_id, callback) {
(async () => {
const raw = await fetch('/_/v1/get/' + doc_id, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
const res = await raw.json();
if (res['code'] != 200) {
console.error("APIv1 error: ", res)
}
callback(res);
})();
},
put: function(data, callback) {
(async () => {
const raw = await fetch('/_/v1/put/' + window.location.pathname, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
'body': data
}),
});
const res = await raw.json();
if (res['code'] != 200) {
console.error("APIv1 error: ", res)
}
callback(res);
})();
},
search: function(space, query, callback) {
(async () => {
const raw = await fetch('/_/v1/search/' + space, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
'body': query
}),
});
const res = await raw.json();
if (res['code'] != 200) {
console.error("APIv1 error: ", res)
}
callback(res);
})();
},
};
const WIDGETS = {};
function parseWidgetSource(html) {
function block(prefix) {
return RegExp('##\\s*' + prefix + '.*?```\\w*(.+?)```', 'gims');
};
const template = block("Template").exec(html)[1];
const css = block("CSS").exec(html)[1];
const processor = block("Processor").exec(html)[1];
var func;
eval("func = " + processor);
return {
template: template,
css: css,
processor: func
};
}
async function processWidgets(html, callback) {
var widgetPattern = /({{(.+)}})/gm;
if (!html.match(widgetPattern)) {
callback();
return;
}
html.matchAll(widgetPattern).forEach(match => {
var widgetTag = match[1];
var widgetName = match[2];
if (Object.values(WIDGETS).indexOf(widgetName) == -1) {
APIv1.search("Widget", widgetName, (res) => {
if (res.code == 200) {
var parts = parseWidgetSource(res.response[0].body);
WIDGETS[widgetName] = parts.processor;
contents = WIDGETS[widgetName](widgetTag, parts.template, parts.css);
} else {
contents = `Invalid widget: ${widgetName}`;
}
var rep = `<span class="widget-${widgetName}" data-source="${widgetTag}">${contents}</span>`;
html = html.replaceAll(widgetTag, rep);
if (parts) {
html = `<style type='text/css'>${parts.css}</style>${html}`;
}
callback(html);
});
}
});
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +0,0 @@
@import 'toastui-editor-viewer.min.css';
#viewer {
display: inline;
}
.toastui-editor-contents {
font-size: var(--default-font-size);
}
@@ -1,6 +0,0 @@
var viewer = new toastui.Editor({
viewer: true,
el: document.querySelector("#viewer"),
usageStatistics: false,
});
viewer.setMarkdown(document.getElementById("data_form__body").value);