4aa3547e11
Gemini.contentFrom to normalize newlines vs HTML junk
197 lines
6.7 KiB
JavaScript
197 lines
6.7 KiB
JavaScript
/* 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 ? `<li>${content}</li>` : ""
|
|
).join("\n")
|
|
break
|
|
case Gemini.type.QUOTE:
|
|
line.dom.innerHTML = line.content.split("\n").map(content =>
|
|
`<div>${content}</div>`
|
|
).join("\n")
|
|
break
|
|
case Gemini.type.PREFORMATTED:
|
|
line.dom.textContent = line.content
|
|
break
|
|
default:
|
|
line.dom.innerHTML = line.content.replace(/\n+/g, "<br>")
|
|
}
|
|
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(/<br>/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 = /((?<href>[^\s]+\/\/[^\s]+)\s)?(?<content>.+)/
|
|
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 = "<br>"
|
|
el.replaceWith(empty)
|
|
}
|
|
if (el.nodeName === "P" && el.firstChild.nodeName === Gemini.type.LINK) {
|
|
el.replaceWith(el.firstChild)
|
|
}
|
|
}
|
|
}
|
|
}
|