/* 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)
}
}
}
}