mirror of
https://github.com/liamcottle/reticulum-meshchat.git
synced 2026-04-28 09:43:13 +00:00
714 lines
25 KiB
JavaScript
714 lines
25 KiB
JavaScript
/**
|
|
* Micron Parser JavaScript implementation
|
|
*
|
|
* micron-parser.js is based on MicronParser.py from NomadNet:
|
|
* https://raw.githubusercontent.com/markqvist/NomadNet/refs/heads/master/nomadnet/ui/textui/MicronParser.py
|
|
*
|
|
* Documentation for the Micron markdown format can be found here:
|
|
* https://raw.githubusercontent.com/markqvist/NomadNet/refs/heads/master/nomadnet/ui/textui/Guide.py
|
|
*/
|
|
class MicronParser {
|
|
|
|
constructor(darkTheme = true) {
|
|
this.darkTheme = darkTheme;
|
|
this.DEFAULT_FG_DARK = "ddd";
|
|
this.DEFAULT_FG_LIGHT = "222";
|
|
this.DEFAULT_BG = "default";
|
|
|
|
this.SELECTED_STYLES = null;
|
|
|
|
this.STYLES_DARK = {
|
|
"plain": { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false },
|
|
"heading1": { fg: "222", bg: "bbb", bold: false, underline: false, italic: false },
|
|
"heading2": { fg: "111", bg: "999", bold: false, underline: false, italic: false },
|
|
"heading3": { fg: "000", bg: "777", bold: false, underline: false, italic: false }
|
|
};
|
|
|
|
this.STYLES_LIGHT = {
|
|
"plain": { fg: this.DEFAULT_FG_LIGHT, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false },
|
|
"heading1": { fg: "000", bg: "777", bold: false, underline: false, italic: false },
|
|
"heading2": { fg: "111", bg: "aaa", bold: false, underline: false, italic: false },
|
|
"heading3": { fg: "222", bg: "ccc", bold: false, underline: false, italic: false }
|
|
};
|
|
|
|
if (this.darkTheme) {
|
|
this.SELECTED_STYLES = this.STYLES_DARK;
|
|
} else {
|
|
this.SELECTED_STYLES = this.STYLES_LIGHT;
|
|
}
|
|
}
|
|
|
|
static formatNomadnetworkUrl(url) {
|
|
return `nomadnetwork://${url}`;
|
|
}
|
|
|
|
convertMicronToHtml(markup) {
|
|
let html = "";
|
|
|
|
let state = {
|
|
literal: false,
|
|
depth: 0,
|
|
fg_color: this.SELECTED_STYLES.plain.fg,
|
|
bg_color: this.DEFAULT_BG,
|
|
formatting: {
|
|
bold: false,
|
|
underline: false,
|
|
italic: false,
|
|
strikethrough: false
|
|
},
|
|
default_align: "left",
|
|
align: "left",
|
|
radio_groups: {}
|
|
};
|
|
|
|
const lines = markup.split("\n");
|
|
|
|
for (let line of lines) {
|
|
const lineOutput = this.parseLine(line, state);
|
|
if (lineOutput && lineOutput.length > 0) {
|
|
for (let el of lineOutput) {
|
|
html += el.outerHTML;
|
|
}
|
|
} else {
|
|
html += "<br>";
|
|
}
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
parseToHtml(markup) {
|
|
// Create a fragment to hold all the Micron output
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
let state = {
|
|
literal: false,
|
|
depth: 0,
|
|
fg_color: this.SELECTED_STYLES.plain.fg,
|
|
bg_color: this.DEFAULT_BG,
|
|
formatting: {
|
|
bold: false,
|
|
underline: false,
|
|
italic: false,
|
|
strikethrough: false
|
|
},
|
|
default_align: "left",
|
|
align: "left",
|
|
radio_groups: {}
|
|
};
|
|
|
|
const lines = markup.split("\n");
|
|
|
|
for (let line of lines) {
|
|
const lineOutput = this.parseLine(line, state);
|
|
if (lineOutput && lineOutput.length > 0) {
|
|
for (let el of lineOutput) {
|
|
fragment.appendChild(el);
|
|
}
|
|
} else {
|
|
fragment.appendChild(document.createElement("br"));
|
|
}
|
|
}
|
|
|
|
return fragment;
|
|
}
|
|
|
|
parseLine(line, state) {
|
|
if (line.length > 0) {
|
|
// Check literals toggle
|
|
if (line === "`=") {
|
|
state.literal = !state.literal;
|
|
return null;
|
|
}
|
|
|
|
if (!state.literal) {
|
|
// Comments
|
|
if (line[0] === "#") {
|
|
return null;
|
|
}
|
|
|
|
// Reset section depth
|
|
if (line[0] === "<") {
|
|
state.depth = 0;
|
|
return this.parseLine(line.slice(1), state);
|
|
}
|
|
|
|
// Section headings
|
|
if (line[0] === ">") {
|
|
let i = 0;
|
|
while (i < line.length && line[i] === ">") {
|
|
i++;
|
|
}
|
|
state.depth = i;
|
|
let headingLine = line.slice(i);
|
|
|
|
if (headingLine.length > 0) {
|
|
// apply heading style if it exists
|
|
let style = null;
|
|
let wanted_style = "heading" + i;
|
|
if (this.SELECTED_STYLES[wanted_style]) {
|
|
style = this.SELECTED_STYLES[wanted_style];
|
|
} else {
|
|
style = this.SELECTED_STYLES.plain;
|
|
}
|
|
|
|
const latched_style = this.stateToStyle(state);
|
|
this.styleToState(style, state);
|
|
|
|
let outputParts = this.makeOutput(state, headingLine);
|
|
this.styleToState(latched_style, state);
|
|
|
|
// wrap in a heading container
|
|
if (outputParts && outputParts.length > 0) {
|
|
const div = document.createElement("div");
|
|
this.applyAlignment(div, state);
|
|
this.applySectionIndent(div, state);
|
|
// merge text nodes
|
|
this.appendOutput(div, outputParts, state);
|
|
return [div];
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// horizontal dividers
|
|
if (line[0] === "-") {
|
|
let dividerChar = "\u2500";
|
|
// if second char given
|
|
if (line.length > 1) {
|
|
dividerChar = line[1];
|
|
}
|
|
const hr = document.createElement("hr");
|
|
this.applySectionIndent(hr, state);
|
|
return [hr];
|
|
}
|
|
|
|
}
|
|
|
|
let outputParts = this.makeOutput(state, line);
|
|
if (outputParts) {
|
|
// outputParts can contain text (tuple) and special objects (fields/checkbox)
|
|
// construct a single line container
|
|
let container = document.createElement("div");
|
|
this.applyAlignment(container, state);
|
|
this.applySectionIndent(container, state);
|
|
this.appendOutput(container, outputParts, state);
|
|
return [container];
|
|
} else {
|
|
// Just empty line
|
|
return [document.createElement("br")];
|
|
}
|
|
|
|
} else {
|
|
// Empty line
|
|
return [document.createElement("br")];
|
|
}
|
|
}
|
|
|
|
applyAlignment(el, state) {
|
|
// use CSS text-align for alignment
|
|
el.style.textAlign = state.align || "left";
|
|
}
|
|
|
|
applySectionIndent(el, state) {
|
|
// indent by state.depth
|
|
let indent = (state.depth - 1) * 2;
|
|
if (indent > 0) {
|
|
el.style.marginLeft = (indent * 10) + "px";
|
|
}
|
|
}
|
|
|
|
// convert current state to a style object
|
|
stateToStyle(state) {
|
|
return {
|
|
fg: state.fg_color,
|
|
bg: state.bg_color,
|
|
bold: state.formatting.bold,
|
|
underline: state.formatting.underline,
|
|
italic: state.formatting.italic
|
|
};
|
|
}
|
|
|
|
styleToState(style, state) {
|
|
if (style.fg !== undefined && style.fg !== null) state.fg_color = style.fg;
|
|
if (style.bg !== undefined && style.bg !== null) state.bg_color = style.bg;
|
|
if (style.bold !== undefined && style.bold !== null) state.formatting.bold = style.bold;
|
|
if (style.underline !== undefined && style.underline !== null) state.formatting.underline = style.underline;
|
|
if (style.italic !== undefined && style.italic !== null) state.formatting.italic = style.italic;
|
|
}
|
|
|
|
appendOutput(container, parts, state) {
|
|
|
|
let currentSpan = null;
|
|
let currentStyle = null;
|
|
|
|
const flushSpan = () => {
|
|
if (currentSpan) {
|
|
container.appendChild(currentSpan);
|
|
currentSpan = null;
|
|
currentStyle = null;
|
|
}
|
|
};
|
|
|
|
for (let p of parts) {
|
|
if (typeof p === 'string') {
|
|
let span = document.createElement("span");
|
|
span.textContent = p;
|
|
container.appendChild(span);
|
|
} else if (Array.isArray(p) && p.length === 2) {
|
|
// tuple: [styleSpec, text]
|
|
let [styleSpec, text] = p;
|
|
// if different style, flush currentSpan
|
|
if (!this.stylesEqual(styleSpec, currentStyle)) {
|
|
flushSpan();
|
|
currentSpan = document.createElement("span");
|
|
this.applyStyleToElement(currentSpan, styleSpec);
|
|
currentStyle = styleSpec;
|
|
}
|
|
currentSpan.textContent += text;
|
|
} else if (p && typeof p === 'object') {
|
|
// field, checkbox, radio, link
|
|
flushSpan();
|
|
if (p.type === "field") {
|
|
let input = document.createElement("input");
|
|
input.type = p.masked ? "password" : "text";
|
|
input.name = p.name;
|
|
input.setAttribute('value', p.data);
|
|
if (p.width) {
|
|
input.size = p.width;
|
|
}
|
|
this.applyStyleToElement(input, this.styleFromState(p.style));
|
|
container.appendChild(input);
|
|
} else if (p.type === "checkbox") {
|
|
let label = document.createElement("label");
|
|
let cb = document.createElement("input");
|
|
cb.type = "checkbox";
|
|
cb.name = p.name;
|
|
cb.value = p.value;
|
|
if (p.prechecked) cb.setAttribute('checked', true);
|
|
label.appendChild(cb);
|
|
label.appendChild(document.createTextNode(" " + p.label));
|
|
this.applyStyleToElement(label, this.styleFromState(p.style));
|
|
container.appendChild(label);
|
|
} else if (p.type === "radio") {
|
|
let label = document.createElement("label");
|
|
let rb = document.createElement("input");
|
|
rb.type = "radio";
|
|
rb.name = p.name;
|
|
rb.value = p.value;
|
|
if (p.prechecked) rb.setAttribute('checked', true);
|
|
label.appendChild(rb);
|
|
label.appendChild(document.createTextNode(" " + p.label));
|
|
this.applyStyleToElement(label, this.styleFromState(p.style));
|
|
container.appendChild(label);
|
|
} else if (p.type === "link") {
|
|
|
|
let directURL = p.url.replace('nomadnetwork://', '').replace('lxmf://', '');
|
|
// use p.url as is for the href
|
|
const formattedUrl = p.url;
|
|
|
|
let a = document.createElement("a");
|
|
a.href = formattedUrl;
|
|
a.title = formattedUrl;
|
|
|
|
let fieldsToSubmit = [];
|
|
let requestVars = {};
|
|
let foundAll = false;
|
|
|
|
if (p.fields && p.fields.length > 0) {
|
|
for (const f of p.fields) {
|
|
if (f === '*') {
|
|
// submit all fields
|
|
foundAll = true;
|
|
} else if (f.includes('=')) {
|
|
// this is a request variable (key=value)
|
|
const [k, v] = f.split('=');
|
|
requestVars[k] = v;
|
|
} else {
|
|
// this is a field name to submit
|
|
fieldsToSubmit.push(f);
|
|
}
|
|
}
|
|
|
|
let fieldStr = '';
|
|
if (foundAll) {
|
|
// if '*' was found, submit all fields
|
|
fieldStr = '*';
|
|
} else {
|
|
fieldStr = fieldsToSubmit.join('|');
|
|
}
|
|
|
|
// append request variables directly to the directURL as query parameters
|
|
const varEntries = Object.entries(requestVars);
|
|
if (varEntries.length > 0) {
|
|
const queryString = varEntries.map(([k, v]) => `${k}=${v}`).join('|');
|
|
|
|
directURL += directURL.includes('`') ? `|${queryString}` : `\`${queryString}`;
|
|
}
|
|
|
|
a.setAttribute("onclick", `event.preventDefault(); onNodePageUrlClick('${directURL}', '${fieldStr}', false, false)`);
|
|
} else {
|
|
// no fields or request variables, just handle the direct URL
|
|
a.setAttribute("onclick", `event.preventDefault(); onNodePageUrlClick('${directURL}', null, false, false)`);
|
|
}
|
|
|
|
a.textContent = p.label;
|
|
this.applyStyleToElement(a, this.styleFromState(p.style));
|
|
container.appendChild(a);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
flushSpan();
|
|
}
|
|
|
|
stylesEqual(s1, s2) {
|
|
if (!s1 && !s2) return true;
|
|
if (!s1 || !s2) return false;
|
|
return (s1.fg === s2.fg && s1.bg === s2.bg && s1.bold === s2.bold && s1.underline === s2.underline && s1.italic === s2.italic);
|
|
}
|
|
|
|
styleFromState(stateStyle) {
|
|
// stateStyle is a name of a style or a style object
|
|
// in this code, p.style is actually a style name. j,ust return that
|
|
return stateStyle;
|
|
}
|
|
|
|
applyStyleToElement(el, style) {
|
|
if (!style) return;
|
|
// convert style fg/bg to colors
|
|
let fgColor = this.colorToCss(style.fg);
|
|
let bgColor = this.colorToCss(style.bg);
|
|
|
|
if (fgColor && fgColor !== "default") {
|
|
el.style.color = fgColor;
|
|
}
|
|
if (bgColor && bgColor !== "default") {
|
|
el.style.backgroundColor = bgColor;
|
|
}
|
|
|
|
if (style.bold) {
|
|
el.style.fontWeight = "bold";
|
|
}
|
|
if (style.underline) {
|
|
el.style.textDecoration = (el.style.textDecoration ? el.style.textDecoration + " underline" : "underline");
|
|
}
|
|
if (style.italic) {
|
|
el.style.fontStyle = "italic";
|
|
}
|
|
}
|
|
|
|
colorToCss(c) {
|
|
if (!c || c === "default") return null;
|
|
// if 3 hex chars (like '222') => expand to #222
|
|
if (c.length === 3 && /^[0-9a-fA-F]{3}$/.test(c)) {
|
|
return "#" + c;
|
|
}
|
|
// If 6 hex chars
|
|
if (c.length === 6 && /^[0-9a-fA-F]{6}$/.test(c)) {
|
|
return "#" + c;
|
|
}
|
|
// If grayscale 'gxx'
|
|
if (c.length === 3 && c[0] === 'g') {
|
|
// treat xx as a number and map to gray
|
|
let val = parseInt(c.slice(1),10);
|
|
if (isNaN(val)) val = 50;
|
|
// map 0-99 scale to a gray hex
|
|
let h = Math.floor(val*2.55).toString(16).padStart(2,'0');
|
|
return "#" + h + h + h;
|
|
}
|
|
|
|
// fallback: just return a known CSS color or tailwind class if not known
|
|
return null;
|
|
}
|
|
|
|
makeOutput(state, line) {
|
|
if (state.literal) {
|
|
// literal mode: output as is, except if `= line
|
|
if (line === "\\`=") {
|
|
line = "`=";
|
|
}
|
|
return [[this.stateToStyle(state), line]];
|
|
}
|
|
|
|
let output = [];
|
|
let part = "";
|
|
let mode = "text";
|
|
let escape = false;
|
|
let skip = 0;
|
|
let i = 0;
|
|
while (i < line.length) {
|
|
let c = line[i];
|
|
if (skip > 0) {
|
|
skip--;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (mode === "formatting") {
|
|
// Handle formatting commands
|
|
switch(c) {
|
|
case '_':
|
|
state.formatting.underline = !state.formatting.underline;
|
|
break;
|
|
case '!':
|
|
state.formatting.bold = !state.formatting.bold;
|
|
break;
|
|
case '*':
|
|
state.formatting.italic = !state.formatting.italic;
|
|
break;
|
|
case 'F':
|
|
// next 3 chars = fg color
|
|
if (line.length >= i+4) {
|
|
let color = line.substr(i+1,3);
|
|
state.fg_color = color;
|
|
skip = 3;
|
|
}
|
|
break;
|
|
case 'f':
|
|
// reset fg
|
|
state.fg_color = this.SELECTED_STYLES.plain.fg;
|
|
break;
|
|
case 'B':
|
|
if (line.length >= i+4) {
|
|
let color = line.substr(i+1,3);
|
|
state.bg_color = color;
|
|
skip = 3;
|
|
}
|
|
break;
|
|
case 'b':
|
|
// reset bg
|
|
state.bg_color = this.DEFAULT_BG;
|
|
break;
|
|
case '`':
|
|
// reset all formatting
|
|
state.formatting.bold = false;
|
|
state.formatting.underline = false;
|
|
state.formatting.italic = false;
|
|
state.fg_color = this.SELECTED_STYLES.plain.fg;
|
|
state.bg_color = this.DEFAULT_BG;
|
|
state.align = state.default_align;
|
|
break;
|
|
case 'c':
|
|
state.align = (state.align === "center") ? state.default_align : "center";
|
|
break;
|
|
case 'l':
|
|
state.align = (state.align === "left") ? state.default_align : "left";
|
|
break;
|
|
case 'r':
|
|
state.align = (state.align === "right") ? state.default_align : "right";
|
|
break;
|
|
case 'a':
|
|
state.align = state.default_align;
|
|
break;
|
|
case '<':
|
|
// Flush current text first
|
|
if (part.length > 0) {
|
|
output.push([this.stateToStyle(state), part]);
|
|
part = "";
|
|
}
|
|
let fieldData = this.parseField(line, i, state);
|
|
if (fieldData) {
|
|
output.push(fieldData.obj);
|
|
i += fieldData.skip;
|
|
continue;
|
|
}
|
|
break;
|
|
|
|
case '[':
|
|
// flush current text first
|
|
if (part.length > 0) {
|
|
output.push([this.stateToStyle(state), part]);
|
|
part = "";
|
|
}
|
|
let linkData = this.parseLink(line, i, state);
|
|
if (linkData) {
|
|
output.push(linkData.obj);
|
|
// mode = "text";
|
|
i += linkData.skip;
|
|
continue;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// unknown formatting char, ignore
|
|
break;
|
|
}
|
|
mode = "text";
|
|
if (part.length > 0) {
|
|
// no flush needed, no text added
|
|
}
|
|
} else {
|
|
// mode === "text"
|
|
if (c === '\\') {
|
|
if (escape) {
|
|
// was escaped backslash
|
|
part += c;
|
|
escape = false;
|
|
} else {
|
|
escape = true;
|
|
}
|
|
} else if (c === '`') {
|
|
if (escape) {
|
|
// just a literal backtick
|
|
part += c;
|
|
escape = false;
|
|
} else {
|
|
// switch to formatting mode
|
|
if (part.length > 0) {
|
|
output.push([this.stateToStyle(state), part]);
|
|
part = "";
|
|
}
|
|
mode = "formatting";
|
|
}
|
|
} else {
|
|
if (escape) {
|
|
part += '\\';
|
|
escape = false;
|
|
}
|
|
part += c;
|
|
}
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
// end of line
|
|
if (part.length > 0) {
|
|
output.push([this.stateToStyle(state), part]);
|
|
}
|
|
|
|
return (output.length > 0) ? output : null;
|
|
}
|
|
|
|
parseField(line, startIndex, state) {
|
|
let field_start = startIndex + 1;
|
|
let backtick_pos = line.indexOf('`', field_start);
|
|
if (backtick_pos === -1) return null;
|
|
|
|
let field_content = line.substring(field_start, backtick_pos);
|
|
let field_masked = false;
|
|
let field_width = 24;
|
|
let field_type = "field";
|
|
let field_name = field_content;
|
|
let field_value = "";
|
|
let field_prechecked = false;
|
|
|
|
if (field_content.includes('|')) {
|
|
let f_components = field_content.split('|');
|
|
let field_flags = f_components[0];
|
|
field_name = f_components[1];
|
|
|
|
if (field_flags.includes('^')) {
|
|
field_type = "radio";
|
|
field_flags = field_flags.replace('^','');
|
|
} else if (field_flags.includes('?')) {
|
|
field_type = "checkbox";
|
|
field_flags = field_flags.replace('?','');
|
|
} else if (field_flags.includes('!')) {
|
|
field_masked = true;
|
|
field_flags = field_flags.replace('!','');
|
|
}
|
|
|
|
if (field_flags.length > 0) {
|
|
let w = parseInt(field_flags,10);
|
|
if (!isNaN(w)) {
|
|
field_width = Math.min(w,256);
|
|
}
|
|
}
|
|
|
|
if (f_components.length > 2) {
|
|
field_value = f_components[2];
|
|
}
|
|
|
|
if (f_components.length > 3) {
|
|
if (f_components[3] === '*') {
|
|
field_prechecked = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
let field_end = line.indexOf('>', backtick_pos);
|
|
if (field_end === -1) return null;
|
|
|
|
let field_data = line.substring(backtick_pos+1, field_end);
|
|
let style = this.stateToStyle(state);
|
|
|
|
let obj = null;
|
|
if (field_type === "checkbox" || field_type === "radio") {
|
|
obj = {
|
|
type: field_type,
|
|
name: field_name,
|
|
value: field_value || field_data,
|
|
label: field_data,
|
|
prechecked: field_prechecked,
|
|
style: style
|
|
};
|
|
} else {
|
|
obj = {
|
|
type: "field",
|
|
name: field_name,
|
|
width: field_width,
|
|
masked: field_masked,
|
|
data: field_data,
|
|
style: style
|
|
};
|
|
}
|
|
|
|
let skip = (field_end - startIndex) + 2;
|
|
return { obj: obj, skip: skip };
|
|
}
|
|
|
|
parseLink(line, startIndex, state) {
|
|
let endpos = line.indexOf(']', startIndex);
|
|
if (endpos === -1) return null;
|
|
|
|
let link_data = line.substring(startIndex+1, endpos);
|
|
let link_components = link_data.split('`');
|
|
let link_label = "";
|
|
let link_url = "";
|
|
let link_fields = "";
|
|
|
|
if (link_components.length === 1) {
|
|
link_label = "";
|
|
link_url = link_data;
|
|
} else if (link_components.length === 2) {
|
|
link_label = link_components[0];
|
|
link_url = link_components[1];
|
|
} else if (link_components.length === 3) {
|
|
link_label = link_components[0];
|
|
link_url = link_components[1];
|
|
link_fields = link_components[2];
|
|
}
|
|
|
|
if (link_url.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (link_label === "") {
|
|
link_label = link_url;
|
|
}
|
|
|
|
// format the URL
|
|
link_url = MicronParser.formatNomadnetworkUrl(link_url);
|
|
|
|
let style = this.stateToStyle(state);
|
|
let obj = {
|
|
type: "link",
|
|
url: link_url,
|
|
label: link_label,
|
|
fields: (link_fields ? link_fields.split("|") : []),
|
|
style: style
|
|
};
|
|
|
|
let skip = (endpos - startIndex) + 2;
|
|
return { obj: obj, skip: skip };
|
|
}
|
|
|
|
}
|
|
|
|
export default MicronParser;
|