~talon/gmi-web

f5ae88e26d78b102c315fdfe77a6441645772455 — Talon Poole a month ago d58ca6a main
so long and thanks for all the fish
9 files changed, 1 insertions(+), 424 deletions(-)

D Makefile
M README.md
D docs/css.gmi
D docs/js.gmi
D docs/web.gmi
D gmi.css
D gmi.js
D min/gmi.min.css
D min/gmi.min.js
D Makefile => Makefile +0 -3
@@ 1,3 0,0 @@
build:
	cat gmi.css | minify --css > min/gmi.min.css
	cat gmi.js | minify --js > min/gmi.min.js

M README.md => README.md +1 -6
@@ 1,6 1,1 @@
# gmi-web
## A bridge between Gemini and HTML

[documentation](https://talon.computer/web/)

![CC0](https://licensebuttons.net/p/zero/1.0/80x15.png)
[This project has moved to Codeberg!](https://codeberg.org/talon/gmi-web)

D docs/css.gmi => docs/css.gmi +0 -43
@@ 1,43 0,0 @@
# gmi.css
## rem based stylesheet for text-focused content inspired by Tachyons.
=> https://tachyons.io/#style Tachyons Style Guide

* readable
* predictable
* mobile friendly

```
<meta name="color-scheme" content="dark light">
<link rel="stylesheet" href="https://talon.computer/gmi.min.css"/>
```

### variables
```
: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;
}
```
> You can customize these using the <html> tag of your document. 
```
<html style="style="--foreground:#555555; --background:#9eebcf;">
```

### Download gmi.css today!
=> //talon.computer/gmi.css gmi.css
=> //talon.computer/gmi.min.css gmi.min.css

=> /js/ Psst! gmi.js might be interesting to you as well– check it out! :)

CC0

D docs/js.gmi => docs/js.gmi +0 -50
@@ 1,50 0,0 @@
# gmi.js
## A bridge between the DOM and Gemini

gmi.js is made up of lines!
```js
const line = Gemini.line("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 = "UL"
line.content = "now\nit's\na\nlist"
```

A document provides a way to handle many lines together:
```js
window.gmi = new Gemini(document.body)
window.gmi.lines = [
  Gemini.line("interesting", "H1"),
  Gemini.line("that's convenient"),
  Gemini.line("http://talon.computer/js/ now... take me back please", "A"),
]
window.gmi.lines[0].type = "H3"
```
> the gemtext source is available via .source
```js
window.gmi.source
```
> or maybe a .gmi file would be easier?
```js
window.gmi.download()
```

All the gmi.css variables are also available as properties.
```
let foreground = window.gmi.foreground
let background = window.gmi.background
window.gmi.foreground = background
window.gmi.background = foreground
```
=> /css/ learn more about gmi.css

=> https://git.sr.ht/~talon/gmi-web/tree gmi.js is licensed under CC0 and available on sourcehut as apart of gmi-web

### [WIP] editing!
* enter creates new P
* shift + enter creates newline
* backspace at beginning of line doesn't remove DOM (+ delete on empty lines)
* cross-platform action for changing line type?
* cross-platform action for toggling editable?

D docs/web.gmi => docs/web.gmi +0 -41
@@ 1,41 0,0 @@
# gmi-web

## HTML
Due to the ambiguity of HTML several translations from Gemini exist in the wild. I propose the following standard:
```
UL         ↔ *
BLOCKQUOTE ↔ >
PRE        ↔ ``` 
A          ↔ => 
H[1-3]     ↔ #[##]
P
```
> Empty lines should simply be represented as <p><br></p>. (Some implementations use just <br> but this sets up contenteditable=true to add content to the line and also Gemini has no "empty" line-type just a line that is empty.)
> The <a> for a link should be presented without any parent elements. Many implementations use <div> to enforce "block" styling as opposed to the default "inline" which renders the link next to the previous block instead of below it. But the nested markup adds an unnecessary layer of indirection in semantics and when parsing. If you must wrap the link it should be with a <p> tag and never a <div>. If you do not wrap the link a simple "a {display: block}" has the same effect (gmi.css uses this).

P, UL, BLOCKQUOTE, and PRE may also have line-breaks which should be inserted as innerHTML using the following rules:
```
P          ↔ <br>
BLOCKQUOTE ↔ <div><br></div>
UL         ↔ <li><br></li>
PRE          \n (or <br>)
```
> These are informed by what the browser uses when contenteditable=true is set on the element and you hit "enter"
> Some implementations render a series of ">" into a series of <blockquotes> which is probably fine but it is preferable to group them and insert the subsequent lines as <div>line-breaks</div>.
> Parsers may want to be aware of potential <br> lines inside <pre> tags as that is how "enter" is handled when contenteditable=true. It is uncertain why the browser behaves so but they can be safely translated to \n. and you need not translate \n → <br> as that's implied in "preformatted".

* accessibility
* pre alt text
* inline media

## CSS
Following this standard will allow gmi.css to render your content reliably and readably across many devices.
=> /css/ read more about gmi.css

## JavaScript
This also paves the way for setting contenteditable on the root element and enabling the browsers native HTML document editor. Unfortunately it does not handle a few annoying quirks which may only be addressed with custom JavaScript.

gmi.js is currently under active development but already exposes a Gemini.line function which wraps the DOM API following the above standard. This should enable addressing the editability quirks and also provide a foundation for future JS/Gemini mashups.
=> /js/ read more about gmi.js 

=> https://git.sr.ht/~talon/gmi-web/ gmi-web is licensed under CC0 and available on sourcehut

D gmi.css => gmi.css +0 -137
@@ 1,137 0,0 @@
* {
    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;
}

@media (prefers-color-scheme:dark) {
    --foreground: white;
    --background: black
}

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);
}

h1,
h2,
h3,
p,
a,
ul,
blockquote {
    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;
}

::selection,
::-moz-selection {
    color: var(--background);
    background-color: var(--foreground);
}

pre::selection,
pre::-moz-selection {
    color: var(--foreground);
    background-color: var(--background);
}

D gmi.js => gmi.js +0 -142
@@ 1,142 0,0 @@
/* gmi.js is licensed under CC0 */
class Gemini {
  static syntax = { P: "", A: "=>", UL: "*", BLOCKQUOTE: ">", PRE: "```", H1: "#", H2: "##", H3: "###", }
  static line(line, type) {
    if (typeof line === "string") line = {content: line, type: type || "P"}
    let dom = Gemini.render(line).dom
    return {
      get dom() { return dom },
      get type() { return this.dom.nodeName },
      set type(type) { dom = Gemini.render({dom: this.dom, type, content: Gemini.contentFrom(this.dom)}).dom },
      get content() { return Gemini.contentFrom(dom) },
      set content(content) { 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.toUpperCase()) {
          case "PRE":
            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 || "P")
    }
    line.dom.contentEditable = line.editable || "inherit"
    switch (line.type.toUpperCase()) {
      case "A":
        const {href, content} = Gemini.link(line.content)
        line.dom.innerHTML = line.editable && href !== content ? `${href} ${content}` :  content 
        line.dom.href = href 
        break
      case "UL": 
        line.dom.innerHTML = line.content.split("\n").map(content => content.length > 0 ? 
          `<li>${content}</li>` : ""
        ).join("\n")
        break
      case "BLOCKQUOTE": 
        line.dom.innerHTML = line.content.split("\n").map(content => 
          `<div>${content}</div>`
        ).join("\n")
        break
      case "PRE":
        line.dom.textContent = line.content
        break
      default:
        line.dom.innerHTML = line.content.replace(/\n+/g, "<br>") 
    }
    return line 
  }
  static contentFrom(dom) {
    switch (dom.nodeName.toUpperCase()) {
      case "BLOCKQUOTE":
        return Array.from(dom.childNodes).map(child => 
          child.textContent
        ).join("\n")
        break
      case "UL": 
        return Array.from(dom.children).map(child => 
          child.textContent
        ).join("\n")
        break
      case "A": 
        const {href, content} = Gemini.link(dom.textContent)
        return `${href || dom.href} ${content}`
        break
      case "PRE": 
        return dom.textContent
        break
      default:
        return dom.innerHTML.replace(/<br>/g, "\n")
    }
  }
  // TODO: rename/move/idk this is awk
  static link(content = "") { return /((?<href>[^\s]+\/\/[^\s]+)\s)?(?<content>.+)/.exec(content).groups }

  constructor(root) { this.root = root }
  get editable() { return this.root.contentEditable === "true" }
  set editable(value) {
    this.root.contentEditable = value 
    this.lines.forEach(line => line.editable = value)
  }
  get lines() {
    return Array.from(this.root.children).filter(el => [
      "P", "BLOCKQUOTE", "A", "PRE", "UL", "H1", "H2", "H3"
    ].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") }
  // TODO: set source is a DOM thing, but parsing should be isomorphic
  // set source(gmi) {}
  download() {
    const el = document.createElement('a')
    el.setAttribute('href', 'data:text/gemini;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)
  }
  get foreground() { return getComputedStyle(this.root).getPropertyValue("--foreground") }
  set foreground(value) { return this.root.style.setProperty("--foreground", value) }
  get background() { return getComputedStyle(this.root).getPropertyValue("--background") }
  set background(value) { return this.root.style.setProperty("--background", value) }
  get size() { return getComputedStyle(this.root).getPropertyValue("--font-size") }
  set size(value) { return this.root.style.setProperty("--font-size", value) }
  get lineHeight() { return getComputedStyle(this.root).getPropertyValue("--line-height") }
  set lineHeight(value) { return this.root.style.setProperty("--line-height", value) }
  get serif() { return getComputedStyle(this.root).getPropertyValue("--serif") }
  set serif(value) { return this.root.style.setProperty("--serif", value) }
  get sans() { return getComputedStyle(this.root).getPropertyValue("--sans") }
  set sans(value) { return this.root.style.setProperty("--sans", value) }
  get mono() { return getComputedStyle(this.root).getPropertyValue("--mono") }
  set mono(value) { return this.root.style.setProperty("--mono", value) }
}

D min/gmi.min.css => min/gmi.min.css +0 -1
@@ 1,1 0,0 @@
*{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)}

D min/gmi.min.js => min/gmi.min.js +0 -1
@@ 1,1 0,0 @@
class Gemini{static syntax={P:"",A:"=>",UL:"*",BLOCKQUOTE:">",PRE:"```",H1:"#",H2:"##",H3:"###"};static line(e,t){"string"==typeof e&&(e={content:e,type:t||"P"});let n=Gemini.render(e).dom;return{get dom(){return n},get type(){return this.dom.nodeName},set type(e){n=Gemini.render({dom:this.dom,type:e,content:Gemini.contentFrom(this.dom)}).dom},get content(){return Gemini.contentFrom(n)},set content(e){Gemini.render({dom:n,type:n.nodeName,content:e})},get editable(){return"true"===this.dom.contentEditable},set editable(e){Gemini.render({dom:this.dom,type:this.type,content:this.content,editable:e})},delete(){return this.dom.remove()},get gmi(){const e=Gemini.syntax[this.type],t=Gemini.contentFrom(this.dom).replace(/\n?$/,"");switch(this.type.toUpperCase()){case"PRE":return`${e}\n${t}\n${e}`;default:return t.split("\n").map((t=>`${""!==e?e+" ":""}${t}`)).join("\n")}},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)}}}static render(e){if(e.dom&&e.dom.nodeName!==e.type){const t=document.createElement(e.type);e.dom.replaceWith(t),e.dom=t}else e.nodeName?e={dom:e,type:e.nodeName,content:Gemini.contentFrom(e),editable:e.contentEditable}:e.dom=e.dom||document.createElement(e.type||"P");switch(e.dom.contentEditable=e.editable||"inherit",e.type.toUpperCase()){case"A":const{href:t,content:n}=Gemini.link(e.content);e.dom.innerHTML=e.editable&&t!==n?`${t} ${n}`:n,e.dom.href=t;break;case"UL":e.dom.innerHTML=e.content.split("\n").map((e=>e.length>0?`<li>${e}</li>`:"")).join("\n");break;case"BLOCKQUOTE":e.dom.innerHTML=e.content.split("\n").map((e=>`<div>${e}</div>`)).join("\n");break;case"PRE":e.dom.textContent=e.content;break;default:e.dom.innerHTML=e.content.replace(/\n+/g,"<br>")}return e}static contentFrom(e){switch(e.nodeName.toUpperCase()){case"BLOCKQUOTE":return Array.from(e.childNodes).map((e=>e.textContent)).join("\n");case"UL":return Array.from(e.children).map((e=>e.textContent)).join("\n");case"A":const{href:t,content:n}=Gemini.link(e.textContent);return`${t||e.href} ${n}`;case"PRE":return e.textContent;default:return e.innerHTML.replace(/<br>/g,"\n")}}static link(e=""){return/((?<href>[^\s]+\/\/[^\s]+)\s)?(?<content>.+)/.exec(e).groups}constructor(e){this.root=e}get editable(){return"true"===this.root.contentEditable}set editable(e){this.root.contentEditable=e,this.lines.forEach((t=>t.editable=e))}get lines(){return Array.from(this.root.children).filter((e=>["P","BLOCKQUOTE","A","PRE","UL","H1","H2","H3"].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/gemini;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)}get foreground(){return getComputedStyle(this.root).getPropertyValue("--foreground")}set foreground(e){return this.root.style.setProperty("--foreground",e)}get background(){return getComputedStyle(this.root).getPropertyValue("--background")}set background(e){return this.root.style.setProperty("--background",e)}get size(){return getComputedStyle(this.root).getPropertyValue("--font-size")}set size(e){return this.root.style.setProperty("--font-size",e)}get lineHeight(){return getComputedStyle(this.root).getPropertyValue("--line-height")}set lineHeight(e){return this.root.style.setProperty("--line-height",e)}get serif(){return getComputedStyle(this.root).getPropertyValue("--serif")}set serif(e){return this.root.style.setProperty("--serif",e)}get sans(){return getComputedStyle(this.root).getPropertyValue("--sans")}set sans(e){return this.root.style.setProperty("--sans",e)}get mono(){return getComputedStyle(this.root).getPropertyValue("--mono")}set mono(e){return this.root.style.setProperty("--mono",e)}}