/* gmi.js is licensed under CC0 */ class Gemini { static line(line) { let dom = Gemini.render(line).dom return { get dom() { return dom }, get type() { return this.dom.nodeName }, set type(type) { dom = Gemini.render({dom, type, content: Gemini.contentFrom(dom)}).dom }, get content() { return Gemini.contentFrom(dom) }, set content(content) { dom = Gemini.render({dom, type: dom.nodeName, content}) }, get editable() { return this.dom.contentEditable === "true" }, set editable(value) { Gemini.render({dom: this.dom, type: this.type, content: this.content, editable: value}) }, delete() { return this.dom.remove() }, get gmi() { const syntax = Gemini.syntax[this.type] const content = Gemini.contentFrom(this.dom).replace(/\n?$/, "") switch (this.type) { case Gemini.type.PREFORMATTED: return `${syntax}\n${content}\n${syntax}` break default: return content.split("\n").map(line => `${syntax !== "" ? syntax + " " : ""}${line}` ).join("\n") } }, get before() { return Gemini.line(this.dom.previousElementSibling) }, set before(line) { this.before.dom.after(line.dom) }, get after() { return Gemini.line(this.dom.nextElementSibling) }, set after(line) { this.after.dom.before(line.dom) }, } } static render(line) { if (line.dom && line.dom.nodeName !== line.type) { const replacement = document.createElement(line.type) line.dom.replaceWith(replacement) line.dom = replacement } else if (line.nodeName) { line = { dom: line, type: line.nodeName, content: Gemini.contentFrom(line), editable: line.contentEditable } } else { line.dom = line.dom || document.createElement(line.type || Gemini.type.TEXT) } line.dom.contentEditable = line.editable || "inherit" switch (line.type) { case Gemini.type.LINK: const {href, content} = Gemini.PARSE_LINK.exec(line.content).groups line.dom.innerHTML = line.editable && href !== content ? `${href} ${content}` : content line.dom.href = href break case Gemini.type.LIST: line.dom.innerHTML = line.content.split("\n").map(content => content.length > 0 ? `
  • ${content}
  • ` : "" ).join("\n") break case Gemini.type.QUOTE: line.dom.innerHTML = line.content.split("\n").map(content => `
    ${content}
    ` ).join("\n") break case Gemini.type.PREFORMATTED: line.dom.textContent = line.content break default: line.dom.innerHTML = line.content.replace(/\n+/g, "
    ") } return line } static contentFrom(dom) { switch (dom.nodeName) { case Gemini.type.QUOTE: return Array.from(dom.childNodes).map(child => child.textContent ).join("\n") break case Gemini.type.LIST: return Array.from(dom.children).map(child => child.textContent ).join("\n") break case Gemini.type.LINK: const {href, content} = Gemini.PARSE_LINK.exec(dom.textContent).groups return `${href || dom.href} ${content}` break case Gemini.type.PREFORMATTED: return dom.textContent break default: return dom.innerHTML.replace(/
    /g, "\n") } } get lines() { return Array.from(this.root.children) .filter(el => Object.values(Gemini.type).includes(el.nodeName)) .map(Gemini.line) } set lines(lines) { this.root.textContent = "" this.root.append(...lines.map(line => line.dom)) } get source() { return this.lines.map(line => line.gmi).join("\n") } set source(gmi) { this.lines = Gemini.parse(gmi).map(([content, type]) => Gemini.line({type, content})) } download() { const el = document.createElement('a') el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.source)) el.setAttribute('download', `${this.lines[0].content.replace(/\s/g, "_")}.gmi`) el.style.display = 'none' document.body.appendChild(el); el.click(); document.body.removeChild(el) } // TODO: Refactor all of this into something cohesive static PARSE_LINK = /((?[^\s]+\/\/[^\s]+)\s)?(?.+)/ static type = { TEXT: "P", LINK: "A", LIST: "UL", QUOTE: "BLOCKQUOTE", PREFORMATTED: "PRE", H1: "H1", H2: "H2", H3: "H3", } static syntax = { [Gemini.type.TEXT]: "", [Gemini.type.LINK]: "=>", [Gemini.type.LIST]: "*", [Gemini.type.QUOTE]: ">", [Gemini.type.PREFORMATTED]: "```", [Gemini.type.H1]: "#", [Gemini.type.H2]: "##", [Gemini.type.H3]: "###", } static get nodeName() { return Object.entries(Gemini.syntax).reduce((flipped, entry) => { const [key, value] = entry flipped[value] = key return flipped }, {}) } static parse(gmi) { let lines = gmi.split("\n") let parsed = [] while (lines.length) { const line = lines.shift() const syntax = /^[^\s]+/ const type = syntax.test(line) ? Gemini.nodeName[line.match(syntax)[0]] || Gemini.type.TEXT : Gemini.type.TEXT let content = "" let until switch (type) { case Gemini.type.PREFORMATTED: until = lines.findIndex(line => line === Gemini.syntax[Gemini.type.PREFORMATTED]) content = lines.slice(0, until).join("\n") lines = lines.slice(until + 1) break case Gemini.type.QUOTE: until = lines.findIndex(line => /[^>]/.test(line)) content = [line, ...lines].slice(0, until + 1).map(line => line.replace("> ", "")).join("\n") lines = lines.slice(until) break case Gemini.type.LIST: until = lines.findIndex(line => /[^\*]/.test(line)) content = [line, ...lines].slice(0, until + 2).map(line => line.replace("* ", "")).join("\n") lines = lines.slice(until + 1) break case Gemini.type.TEXT: content = line break default: content = line.replace(syntax, "") } parsed.push([content.length === 0 ? "\n" : content.trim(), type]) } return parsed } constructor(root) { this.root = root // TODO: reference gmi2html implementation, fix these quirks for (let el of this.root.childNodes) { if (el.nodeName === "BR") { let empty = document.createElement(Gemini.type.TEXT) empty.innerHTML = "
    " el.replaceWith(empty) } if (el.nodeName === "P" && el.firstChild.nodeName === Gemini.type.LINK) { el.replaceWith(el.firstChild) } } } }