gmi.js redesign

This commit is contained in:
Talon Poole 2020-12-24 00:23:31 +00:00
parent 66c615d88a
commit 9d2bbfef0a
12 changed files with 269 additions and 279 deletions

View file

@ -1,3 +1,4 @@
build:
cat gmi.css | minify --css > gmi.min.css
cat gmi.js | minify --js > gmi.min.js
cat gmi.css | minify --css > min/gmi.min.css
cat gmi.js | minify --js > min/gmi.min.js
cat gmi.editor.js | minify --js > min/gmi.editor.min.js

View file

@ -1,23 +1,5 @@
# gmi-web
## A bridge between Gemini and HTML
### gmi.css
> rem based stylesheet for text-focused content inspired by Tachyons.
* readable
* predictable
* mobile friendly
live example/docs: [gmi.css](https://talon.computer/css/)
### gmi.js
> an optional extension to gmi.css
* light/dark mode
* click to copy pre blocks
* inline images, audio and video
* themeable
live example/docs: [gmi.js](https://talon.computer/js/)
### [gmi.css](https://talon.computer/css/)
### [gmi.js](https://talon.computer/js/)
![CC0](https://licensebuttons.net/p/zero/1.0/80x15.png)

52
docs/js.gmi Normal file
View file

@ -0,0 +1,52 @@
# [WIP] gmi.js
## A bridge between the DOM and Gemini
gmi.js is made up of lines! Use ctrl+shift+i to open a console and paste this in:
```js
const line = Gemini.line({
type: Gemini.type.LIST,
content: "manipulate the dom\nbut like in a Gemini way\ntry it!"
})
document.body.prepend(line.dom)
```
> now try changing the type and content and observing the effects.
```js
line.type = Gemini.type.QUOTE
line.content = "now it's a quote"
```
A line can be of any type available via Gemini.type and its content should be a string with optional newlines. Use .delete() to remove the line. Checkout the Gemini.line source to see the complete API.
A document provides a way to handle many lines together:
```js
window.gmi = new Gemini(document.body)
window.gmi.lines[2].type = Gemini.TYPE.TEXT
window.gmi.lines = [
Gemini.line({content: "interesting", type: Gemini.type.H1}),
Gemini.line({content: "that's convienient"}),
]
```
> you can even work with the source directly
```js
console.log(window.gmi.source)
window.gmi.source = "=> https://talon.computer/js/ uh, go back!"
```
> view the source as an editable pre block by setting it as the content of a Gemini.type.PREFORMATTED line ;)
```js
window.gmi.lines = [Gemini.line({
content: window.gmi.source,
type: Gemini.type.PREFORMATTED,
editable: true
})]
```
> render any changes you may have made
```js
window.gmi.source = window.gmi.lines[0].content
```
> or maybe a .gmi file would be easier?
```js
window.gmi.download()
```
=> /gmi.js view/download (minified version available via .min.js)
CC0

View file

@ -83,6 +83,7 @@ a {
font-size: var(--font-size);
font-family: var(--serif);
text-decoration: none;
display: block;
}
li::before {

17
gmi.editor.js Normal file
View file

@ -0,0 +1,17 @@
/* gmi.editor.js is licensed under CCO */
class GeminiEditor {
constructor(gmi) {
this.gmi = gmi
}
static next(type) {
return function (e) {
e.preventDefault()
const types = Object.values(Gemini.TYPE)
console.log(content)
let next = types.indexOf(type) + 1
next = next <= types.length - 1 ? types[next] : types[0]
return next
}
}
}

381
gmi.js
View file

@ -1,202 +1,203 @@
/* gmi.js is licensed under CC0 */
class Gemini {
constructor({modes} = {modes: false}) {
this.modes = modes
this.allThemes = {
default: {
icon: "🎨",
colors: [
getComputedStyle(document.documentElement).getPropertyValue(`--foreground`),
getComputedStyle(document.documentElement).getPropertyValue(`--background`)
],
static line(line = {}) {
let dom = line.nodeName ? line : document.createElement(line.type || Gemini.type.TEXT)
if (line.editable) dom.contentEditable = line.editable
switch (line.type) {
case Gemini.type.LINK:
const {href, content} = Gemini.PARSE_LINK.exec(line.content).groups
dom.href = line.href || href || content
dom.innerHTML = Gemini.innerHTML(Object.assign(line, { content, href }))
break
default:
dom.innerHTML = line.innerHTML || Gemini.innerHTML(line)
}
return {
get dom() { return dom },
get type() { return this.dom.nodeName },
set type(type) {
let replacement
switch (line.type) {
case Gemini.type.LINK:
const {href, content} = Gemini.PARSE_LINK.exec(this.content).groups
replacement = {type, href, content, editable: this.editable }
break
default:
replacement = {type, content: this.content, editable: this.editable}
}
const line = Gemini.line(replacement)
this.dom.replaceWith(line.dom)
dom = line.dom
},
}
if (this.modes) {
css(`#toggleMode {
font-size: 1rem;
cursor: pointer;
position: fixed;
top: 0.5rem;
right: 0.5rem;
padding: 0.3rem;
margin-top: 0.1rem;
border: none;
color: var(--background);
background-color: var(--foreground);
}`)
this.toggleMode = document.createElement("button")
this.toggleMode.id = "toggleMode"
this.toggleMode.textContent = this.mode
this.toggleMode.onclick = () => {
this.mode = this.mode === "light" ? "dark" : "light"
this.toggleMode.textContent = this.mode
}
}
window.addEventListener("load", () => {
this.mode = this.mode
document.body.append(this.toggleMode)
})
}
set foreground(value) { return this.set("foreground", value) }
get(property) { return document.documentElement.style.getPropertyValue(`--${property}`) }
set(property, value) { return document.documentElement.style.setProperty(`--${property}`, value) }
get foreground() { return this.get("foreground") }
get background() { return this.get("background") }
set background(value) { return this.set("background", value) }
get fontSize() { return this.get("font-size") }
set fontSize(value) { return this.set("font-size", value) }
get mono() { return this.get("mono") }
set mono(value) { return this.set("mono", value) }
get serif() { return this.get("serif") }
set serif(value) { return this.set("serif", value) }
get sansSerif() { return this.get("sans-serif") }
set sansSerif(value) { return this.set("sans-serif", value) }
get mode() { return localStorage.getItem("mode") || "light" }
set mode(value) {
if (!/^(light|dark)$/.test(value))
throw Error("Invalid mode: use either 'light' or 'dark'")
localStorage.setItem("mode", value)
this.colors()
}
get theme() {
const theme = localStorage.getItem("theme")
return !theme || !this.allThemes[theme] ? "default" : theme
}
set theme(theme) {
if (this.themeSwitcher) this.themeSwitcher.textContent = this.allThemes[theme].icon
localStorage.setItem("theme", theme)
this.colors()
}
colors() {
const theme = this.allThemes[this.theme]
const foreground = theme.colors[0]
const background = theme.colors[1]
if (this.mode == "dark") {
this.foreground = background
this.background = foreground
} else {
this.foreground = foreground
this.background = background
}
}
themes(themes, mode) {
this.mode = mode || this.mode
this.allThemes = Object.assign(themes, this.allThemes)
css(`#themeSwitcher {
font-size: 1.5rem;
position: fixed;
top: 0.5rem;
right: ${this.modes ? "3.3rem" : "1rem"};
cursor: pointer;
border: none;
background: none;"
}`)
this.themeSwitcher = document.createElement("button")
this.themeSwitcher.id = "themeSwitcher"
this.themeSwitcher.onclick = () => {
const names = Object.keys(this.allThemes)
let next = names.indexOf(this.theme) + 1
next = next <= names.length - 1 ? next : 0
this.theme = names[next]
}
document.body.append(this.themeSwitcher)
this.theme = this.theme
return this
}
clickToCopy() {
css(`pre:hover {
cursor: pointer;
}
pre:hover::before {
content: "click to copy";
color: var(--foreground);
background-color: var(--background);
float: right;
}`)
for (const pre of document.getElementsByTagName('pre')) {
pre.onclick = () => {
navigator.clipboard.writeText(pre.textContent)
}
}
return this
}
inline({images, audio, video} = {images: false, audio: false, video: false}) {
if(images) {
css(`img {
display: block;
max-width: 100%;
}`)
const IMAGE_EXTENSIONS = /\.(jpeg|jpg|png|gif|bmp|svg|ico|webp|tiff)$/
for (const link of document.getElementsByTagName('a')) {
if (IMAGE_EXTENSIONS.test(link.pathname)) {
const img = document.createElement("img")
img.src = link.href
img.title = link.textContent.replace(/\n$/, "")
link.append(img)
get content() {
switch (this.type) {
case Gemini.type.QUOTE:
return Array.from(this.dom.childNodes).map(child => child.textContent).join("\n")
case Gemini.type.LIST:
return Array.from(this.dom.children).map(child => child.textContent).join("\n")
case Gemini.type.LINK:
const {href, content} = Gemini.PARSE_LINK.exec(this.dom.textContent).groups
return `${href || this.dom.href} ${content}`
case Gemini.type.PREFORMATTED:
return this.dom.textContent
default:
return this.dom.innerHTML.replace(/<br>/g, "\n").replace(/\n?$/, "")
}
}
}
if (audio) {
css(`audio {
width: 100%;
display: block;
}`)
const AUDIO_EXTENSIONS = /\.(mp3|ogg)$/
for (const link of document.getElementsByTagName('a')) {
if (AUDIO_EXTENSIONS.test(link.pathname)) {
const audio = document.createElement("audio")
audio.src = link.href
audio.title = link.textContent.replace(/\n$/, "")
audio.controls = true
audio.currentTime = true
audio.preload = "none"
link.append(audio)
},
set content(value) {
switch (this.type) {
case Gemini.type.LINK:
const {href, content} = Gemini.PARSE_LINK.exec(value).groups
this.dom.innerHTML = Gemini.innerHTML({ type: this.type, content, href })
this.dom.href = href
break
default:
this.dom.innerHTML = Gemini.innerHTML({ type: this.type, content: value })
}
}
}
if (video) {
css(`video {
width: 100%;
display: block;
}`)
const VIDEO_EXTENSIONS = /\.(mp4|webm|ogv|mov)$/
for (const link of document.getElementsByTagName('a')) {
if (VIDEO_EXTENSIONS.test(link.pathname)) {
const video = document.createElement("video")
video.src = link.href
video.title = link.textContent.replace(/\n$/, "")
video.controls = true
video.currentTime = true
video.preload = "metadata"
link.append(video)
},
get gmi() {
switch (this.type) {
case Gemini.type.PREFORMATTED:
return Gemini.syntax[this.type] + `\n${this.content}\n` + Gemini.syntax[this.type]
break
default:
return this.content.split("\n").map(content => `${Gemini.syntax[this.type] !== "" ? Gemini.syntax[this.type] + " ": ""}${content}`).join("\n")
}
},
get editable() { return this.dom.contentEditable === "true" },
set editable(value) {
this.dom.contentEditable = value
switch (this.type) {
case Gemini.type.LINK:
let {href, content} = Gemini.PARSE_LINK.exec(this.content).groups
this.dom.innerHTML = Gemini.innerHTML({ type: this.type, editable: value, content, href })
this.dom.href = href
}
},
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) },
delete() { return this.dom.remove() },
}
}
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") }
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
// perserve newlines between link/heading and multiline types
static innerHTML(line) {
switch (line.type) {
case Gemini.type.LINK:
return line.editable && line.href !== line.content ? `${line.href} ${line.content}` : line.content
case Gemini.type.LIST:
return line.content.split("\n").map(content =>
content.length > 0 ? `<li>${content}</li>` : ""
).join("\n")
case Gemini.type.QUOTE:
return line.content.split("\n").map(content =>
`<div>${content}</div>`
).join("\n")
case Gemini.type.PREFORMATTED:
return line.content
default:
return line.content.replace(/\n+/g, "<br>")
}
}
// TODO: pull parsing out and make it more useful
set source(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({type, content: content.length === 0 ? "\n" : content.trim()})
}
return this
this.lines = parsed.map(Gemini.line)
}
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
}, {})
}
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)
}
}
}
}
function css(css) {
if (!this.style) {
this.style = document.createElement("style")
document.documentElement.append(this.style)
}
this.style.textContent = this.style.textContent + "\n" + css
return this
}

1
gmi.min.js vendored
View file

@ -1 +0,0 @@
class Gemini{constructor({modes:e}={modes:!1}){this.modes=e,this.allThemes={default:{icon:"🎨",colors:[getComputedStyle(document.documentElement).getPropertyValue("--foreground"),getComputedStyle(document.documentElement).getPropertyValue("--background")]}},this.modes&&(css("#toggleMode {\n font-size: 1rem; \n cursor: pointer; \n position: fixed; \n top: 0.5rem; \n right: 0.5rem; \n padding: 0.3rem;\n margin-top: 0.1rem;\n border: none; \n color: var(--background);\n background-color: var(--foreground);\n }"),this.toggleMode=document.createElement("button"),this.toggleMode.id="toggleMode",this.toggleMode.textContent=this.mode,this.toggleMode.onclick=()=>{this.mode="light"===this.mode?"dark":"light",this.toggleMode.textContent=this.mode}),window.addEventListener("load",(()=>{this.mode=this.mode,document.body.append(this.toggleMode)}))}set foreground(e){return this.set("foreground",e)}get(e){return document.documentElement.style.getPropertyValue("--"+e)}set(e,t){return document.documentElement.style.setProperty("--"+e,t)}get foreground(){return this.get("foreground")}get background(){return this.get("background")}set background(e){return this.set("background",e)}get fontSize(){return this.get("font-size")}set fontSize(e){return this.set("font-size",e)}get mono(){return this.get("mono")}set mono(e){return this.set("mono",e)}get serif(){return this.get("serif")}set serif(e){return this.set("serif",e)}get sansSerif(){return this.get("sans-serif")}set sansSerif(e){return this.set("sans-serif",e)}get mode(){return localStorage.getItem("mode")||"light"}set mode(e){if(!/^(light|dark)$/.test(e))throw Error("Invalid mode: use either 'light' or 'dark'");localStorage.setItem("mode",e),this.colors()}get theme(){const e=localStorage.getItem("theme");return e&&this.allThemes[e]?e:"default"}set theme(e){this.themeSwitcher&&(this.themeSwitcher.textContent=this.allThemes[e].icon),localStorage.setItem("theme",e),this.colors()}colors(){const e=this.allThemes[this.theme],t=e.colors[0],o=e.colors[1];"dark"==this.mode?(this.foreground=o,this.background=t):(this.foreground=t,this.background=o)}themes(e,t){return this.mode=t||this.mode,this.allThemes=Object.assign(e,this.allThemes),css(`#themeSwitcher {\n font-size: 1.5rem; \n position: fixed; \n top: 0.5rem; \n right: ${this.modes?"3.3rem":"1rem"}; \n cursor: pointer; \n border: none; \n background: none;"\n }`),this.themeSwitcher=document.createElement("button"),this.themeSwitcher.id="themeSwitcher",this.themeSwitcher.onclick=()=>{const e=Object.keys(this.allThemes);let t=e.indexOf(this.theme)+1;t=t<=e.length-1?t:0,this.theme=e[t]},document.body.append(this.themeSwitcher),this.theme=this.theme,this}clickToCopy(){css('pre:hover {\n cursor: pointer;\n }\n pre:hover::before {\n content: "click to copy";\n color: var(--foreground);\n background-color: var(--background);\n float: right;\n }');for(const e of document.getElementsByTagName("pre"))e.onclick=()=>{navigator.clipboard.writeText(e.textContent)};return this}inline({images:e,audio:t,video:o}={images:!1,audio:!1,video:!1}){if(e){css("img {\n display: block;\n max-width: 100%;\n }");const e=/\.(jpeg|jpg|png|gif|bmp|svg|ico|webp|tiff)$/;for(const t of document.getElementsByTagName("a"))if(e.test(t.pathname)){const e=document.createElement("img");e.src=t.href,e.title=t.textContent.replace(/\n$/,""),t.append(e)}}if(t){css("audio {\n width: 100%;\n display: block;\n }");const e=/\.(mp3|ogg)$/;for(const t of document.getElementsByTagName("a"))if(e.test(t.pathname)){const e=document.createElement("audio");e.src=t.href,e.title=t.textContent.replace(/\n$/,""),e.controls=!0,e.currentTime=!0,e.preload="none",t.append(e)}}if(o){css("video {\n width: 100%;\n display: block;\n }");const e=/\.(mp4|webm|ogv|mov)$/;for(const t of document.getElementsByTagName("a"))if(e.test(t.pathname)){const e=document.createElement("video");e.src=t.href,e.title=t.textContent.replace(/\n$/,""),e.controls=!0,e.currentTime=!0,e.preload="metadata",t.append(e)}}return this}}function css(e){return this.style||(this.style=document.createElement("style"),document.documentElement.append(this.style)),this.style.textContent=this.style.textContent+"\n"+e,this}

66
js.gmi
View file

@ -1,66 +0,0 @@
# gmi.js
## an optional extension to gmi.css
=> /css/ gmi.css
* light/dark mode
* click to copy pre blocks
* inline images, audio and video
* themeable
```
<meta name="color-scheme" content="dark light">
<link rel="stylesheet" href="https://talon.computer/gmi.min.css"/>
<script src="https://talon.computer/gmi.min.js"></script>
<script>
window.gmi = new Gemini({ modes: true })
window.onload = () => window.gmi
.clickToCopy()
.inline({ images: true, audio: true, video: true })
.themes({
book: {
icon: "📖",
colors: ["#555555", "#fffceb"]
},
rose: {
icon: "🌹",
colors: ["#555555", "#ffdfdf"]
},
plant: {
icon: "🌱",
colors: ["#555555", "#9eebcf"]
},
ocean: {
icon: "🌊",
colors: ["#555555", "#96ccff"]
},
})
</script>
```
By default gmi.js will determine your theme based on the --foreground and --background CSS variables. You can supply your own themes using the object format shown above. I recommend using readable color palettes.
=> https://tachyons.io/docs/themes/skins/ Tachyons Color Schemes
### Download gmi.js today!
=> //talon.computer/gmi.js gmi.js
=> //talon.computer/gmi.min.js gmi.min.js
# inline examples
## images
=> https://www.learningcontainer.com/wp-content/uploads/2020/09/Sample-ICO-file-Download-for-testing.ico ICO
=> https://www.learningcontainer.com/wp-content/uploads/2020/07/sample-jpg-file-for-testing.jpg JPG
=> https://www.learningcontainer.com/wp-content/uploads/2020/08/Sample-PNG-File-for-Testing.png PNG
=> https://www.learningcontainer.com/wp-content/uploads/2020/08/Sample-SVG-Image-File-Download.svg SVG
=> https://www.learningcontainer.com/wp-content/uploads/2020/08/sample-webp-file-for-testing.webp WEBP
=> https://www.learningcontainer.com/wp-content/uploads/2020/09/Sample-gif-Image-File-Download.gif GIF
## audio
=> https://learn.esperanto.com/assets/ogg/01.ogg OGG
=> https://learn.esperanto.com/assets/mp3/01.mp3 MP3
## video
=> https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4 MP4
=> https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mov-file.mov MOV
=> https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-webm-file.webm WEBM
=> https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-ogv-file.ogv OGV
CC0

1
min/gmi.editor.min.js vendored Normal file
View file

@ -0,0 +1 @@
class GeminiEditor{constructor(t){this.gmi=t}static next(t){return function(e){e.preventDefault();const n=Object.values(Gemini.TYPE);console.log(content);let i=n.indexOf(t)+1;return i=i<=n.length-1?n[i]:n[0],i}}}

1
min/gmi.min.css vendored Normal file
View file

@ -0,0 +1 @@
*{margin:0;padding:0;overflow-wrap:anywhere}:root{--foreground:black;--background:white;--line-height:1.5;--font-size:1.25rem;--mono:Consolas,monaco,monospace;--serif:font-family:georgia,times,serif;--sans-serif:-apple-system,BlinkMacSystemFont,'avenir next',avenir,helvetica,'helvetica neue',ubuntu,roboto,noto,'segoe ui',arial,sans-serif}body{max-width:48rem;background-color:var(--background);padding:.5rem;margin:0 auto}h1,h2,h3{font-family:var(--sans-serif);line-height:1.25}h1{font-size:3rem}h2{font-size:2.25rem}h3{font-size:1.5rem}p{font-size:var(--font-size);font-family:var(--serif);line-height:var(--line-height)}a,blockquote,h1,h2,h3,p,ul{color:var(--foreground);background-color:var(--background)}br{line-height:1}a::before{font-size:var(--font-size);font-family:var(--mono);content:"⇒";padding-right:.25rem;vertical-align:middle}a:hover{color:var(--background);background-color:var(--foreground)}a{font-size:var(--font-size);font-family:var(--serif);text-decoration:none;display:block}li::before{font-size:var(--font-size);font-family:var(--mono);content:"*";vertical-align:middle;padding-right:.5rem}ul{font-size:var(--font-size);font-family:var(--serif);line-height:1.25;list-style-type:none}blockquote{font-size:var(--font-size);font-family:var(--serif);line-height:var(--line-height);border-left:.5rem solid var(--foreground);padding-left:.75rem}pre{font-size:1rem;font-family:var(--mono);line-height:1;color:var(--background);background-color:var(--foreground);padding:1.25rem;overflow-y:auto}pre+blockquote{padding-top:.5rem;padding-bottom:.5rem}::-moz-selection,::selection{color:var(--background);background-color:var(--foreground)}pre::-moz-selection,pre::selection{color:var(--foreground);background-color:var(--background)}

1
min/gmi.min.js vendored Normal file
View file

@ -0,0 +1 @@
class Gemini{static line(e={}){let t=e.nodeName?e:document.createElement(e.type||Gemini.type.TEXT);switch(e.editable&&(t.contentEditable=e.editable),e.type){case Gemini.type.LINK:const{href:n,content:i}=Gemini.PARSE_LINK.exec(e.content).groups;t.href=e.href||n||i,t.innerHTML=Gemini.innerHTML(Object.assign(e,{content:i,href:n}));break;default:t.innerHTML=e.innerHTML||Gemini.innerHTML(e)}return{get dom(){return t},get type(){return this.dom.nodeName},set type(e){let n;switch(i.type){case Gemini.type.LINK:const{href:t,content:i}=Gemini.PARSE_LINK.exec(this.content).groups;n={type:e,href:t,content:i,editable:this.editable};break;default:n={type:e,content:this.content,editable:this.editable}}const i=Gemini.line(n);this.dom.replaceWith(i.dom),t=i.dom},get content(){switch(this.type){case Gemini.type.QUOTE:return Array.from(this.dom.childNodes).map((e=>e.textContent)).join("\n");case Gemini.type.LIST:return Array.from(this.dom.children).map((e=>e.textContent)).join("\n");case Gemini.type.LINK:const{href:e,content:t}=Gemini.PARSE_LINK.exec(this.dom.textContent).groups;return`${e||this.dom.href} ${t}`;case Gemini.type.PREFORMATTED:return this.dom.textContent;default:return this.dom.innerHTML.replace(/<br>/g,"\n").replace(/\n?$/,"")}},set content(e){switch(this.type){case Gemini.type.LINK:const{href:t,content:n}=Gemini.PARSE_LINK.exec(e).groups;this.dom.innerHTML=Gemini.innerHTML({type:this.type,content:n,href:t}),this.dom.href=t;break;default:this.dom.innerHTML=Gemini.innerHTML({type:this.type,content:e})}},get gmi(){switch(this.type){case Gemini.type.PREFORMATTED:return Gemini.syntax[this.type]+`\n${this.content}\n`+Gemini.syntax[this.type];default:return this.content.split("\n").map((e=>`${""!==Gemini.syntax[this.type]?Gemini.syntax[this.type]+" ":""}${e}`)).join("\n")}},get editable(){return"true"===this.dom.contentEditable},set editable(e){switch(this.dom.contentEditable=e,this.type){case Gemini.type.LINK:let{href:t,content:n}=Gemini.PARSE_LINK.exec(this.content).groups;this.dom.innerHTML=Gemini.innerHTML({type:this.type,editable:e,content:n,href:t}),this.dom.href=t}},get before(){return Gemini.line(this.dom.previousElementSibling)},set before(e){this.before.dom.after(e.dom)},get after(){return Gemini.line(this.dom.nextElementSibling)},set after(e){this.after.dom.before(e.dom)},delete(){return this.dom.remove()}}}get lines(){return Array.from(this.root.children).filter((e=>Object.values(Gemini.type).includes(e.nodeName))).map(Gemini.line)}set lines(e){this.root.textContent="",this.root.append(...e.map((e=>e.dom)))}get source(){return this.lines.map((e=>e.gmi)).join("\n")}download(){const e=document.createElement("a");e.setAttribute("href","data:text/plain;charset=utf-8,"+encodeURIComponent(this.source)),e.setAttribute("download",this.lines[0].content.replace(/\s/g,"_")+".gmi"),e.style.display="none",document.body.appendChild(e),e.click(),document.body.removeChild(e)}static innerHTML(e){switch(e.type){case Gemini.type.LINK:return e.editable&&e.href!==e.content?`${e.href} ${e.content}`:e.content;case Gemini.type.LIST:return e.content.split("\n").map((e=>e.length>0?`<li>${e}</li>`:"")).join("\n");case Gemini.type.QUOTE:return e.content.split("\n").map((e=>`<div>${e}</div>`)).join("\n");case Gemini.type.PREFORMATTED:return e.content;default:return e.content.replace(/\n+/g,"<br>")}}set source(e){let t=e.split("\n"),n=[];for(;t.length;){const e=t.shift(),i=/^[^\s]+/,s=i.test(e)&&Gemini.nodeName[e.match(i)[0]]||Gemini.type.TEXT;let r,o="";switch(s){case Gemini.type.PREFORMATTED:r=t.findIndex((e=>e===Gemini.syntax[Gemini.type.PREFORMATTED])),o=t.slice(0,r).join("\n"),t=t.slice(r+1);break;case Gemini.type.QUOTE:r=t.findIndex((e=>/[^>]/.test(e))),o=[e,...t].slice(0,r+1).map((e=>e.replace("> ",""))).join("\n"),t=t.slice(r);break;case Gemini.type.LIST:r=t.findIndex((e=>/[^\*]/.test(e))),o=[e,...t].slice(0,r+2).map((e=>e.replace("* ",""))).join("\n"),t=t.slice(r+1);break;case Gemini.type.TEXT:o=e;break;default:o=e.replace(i,"")}n.push({type:s,content:0===o.length?"\n":o.trim()})}this.lines=n.map(Gemini.line)}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(((e,t)=>{const[n,i]=t;return e[i]=n,e}),{})}constructor(e){this.root=e;for(let e of this.root.childNodes){if("BR"===e.nodeName){let t=document.createElement(Gemini.type.TEXT);t.innerHTML="<br>",e.replaceWith(t)}"P"===e.nodeName&&e.firstChild.nodeName===Gemini.type.LINK&&e.replaceWith(e.firstChild)}}}