kopher

A simple Gopher client for KaiOS
git clone git://git.luxferre.top/kopher.git
Log | Files | Refs | README | LICENSE

hi01379.js (9313B)


      1 // Platform-agnostic Gopher response handler and Gophermap parser
      2 // Depends on the platform-specific gopherRequest(resource, input, success, error) function
      3 // (defined in the transport.js file here)
      4 
      5 Hi01379 = (function(psGopherRequest) {
      6 
      7   var tagsToReplace = {'&': '&amp;', '<': '&lt;', '>': '&gt;'}
      8 
      9   function esc(str) { // escape HTML entities
     10     return str.replace(/[&<>]/g, function(tag) {return tagsToReplace[tag] || tag})
     11   }
     12 
     13   function unphlow(str) { // Unphlow algorithm implementation
     14     var lines=str.split('\n'), line, l = lines.length, i, buf = '', out = []
     15     for(i=0;i<l;i++) {
     16       line = lines[i].trim() // remove all leading/trailing whitespace-class chars
     17       if(line.length) // if the line is not empty, just append it and a whitespace to the buffer 
     18         buf += line + ' '
     19       else { // output logic
     20         out.push(buf, '')
     21         buf = ''
     22       }
     23     }
     24     if(buf.length) // process the remaining output
     25       out.push(buf)
     26     return out.map(function(s) { // final whitespace sanitation
     27       return s.replace(/\s+/g, ' ').trim()
     28     }).join('\n')
     29   }
     30 
     31   function terminalize(str, attrs) { // partial terminal styling support with ANSI codes
     32     if(!attrs) attrs = [] // accept previous attributes array if present
     33     var prevattrs = attrs.join(' ') // preserve stringified copy
     34     var out = '', c, styleseq, sl, l = str.length, i, j, att, attindex, strattrs,
     35         visualOpenTag='<span data-styling="%s">', visualCloseTag='</span>',
     36         modes = ['', 'BB', 'FN', 'IT', 'UL', 'BL', '', 'IN', 'HI', 'ST']
     37     for(i=0;i<l;i++) {
     38       c = str[i]
     39       if(c === '\x1b') { // escape character encountered
     40         c = str[i+1] // get the next character, don't increase the counter yet
     41         if(c === '[') { // ANSI sequence starting
     42           i++ // increase the counter, now it's pointing to [
     43           styleseq='' // init style sequence buffer
     44           do { // read the style sequence until m character or any whitespace
     45             c = str[++i] // increase the counter once more, [ is ignored
     46             styleseq += c
     47           } while(c !== 'm' && c !== ' ' && c !== '\t' && c !== '\r' && c !== '\n')
     48           styleseq = styleseq.slice(0, -1).split(';') // attributes are delimited by ;
     49           for(sl=styleseq.length,j=0;j<sl;j++) { // iterate over attributes in their order
     50             att = parseInt(styleseq[j]) // in this case parseInt is more reliable than |0
     51             if(att === 0) attrs = [] // reset all styling
     52             else if(att >= 30 && att <= 37) attrs.push('FC'+(att-30)) // foreground color
     53             else if(att >= 40 && att <= 47) attrs.push('BC'+(att-40)) // background color
     54             else if(att < 10) attrs.push(modes[att]) // activate special modes
     55             else if(att > 20 && att < 30) { // deactivate special modes
     56               if(att === 22) { // also deactivate bold mode 1
     57                 attindex = attrs.indexOf('BB')
     58                 if(attindex > -1)
     59                   attrs.splice(attindex, 1)
     60               }
     61               attindex = attrs.indexOf(modes[att - 20])
     62               if(attindex > -1)
     63                 attrs.splice(attindex, 1)
     64             }
     65             else if(att === 39) { // reset only the foreground color
     66               attrs = attrs.map(function(v) {
     67                 return v.startsWith('FC') ? '' : v
     68               }).filter(String)
     69             }
     70             else if(att === 49) { // reset only the background color
     71               attrs = attrs.map(function(v) {
     72                 return v.startsWith('BC') ? '' : v
     73               }).filter(String)
     74             }
     75           }
     76           // now we have our attrs array with all styling applied, output the tags
     77           strattrs = attrs.join(' ')
     78           if(strattrs != prevattrs) { // there are some changes
     79             if(prevattrs != '') // previous attributes were not empty
     80               out += visualCloseTag
     81             if(strattrs) // current attributes are not empty
     82               out += visualOpenTag.replace('%s', strattrs)
     83             prevattrs = strattrs // update the previous attribute cache
     84           }
     85         } // don't add any processed characters "as is" to the output
     86         else continue // skip the escape and proceed with the loop
     87       }
     88       else out += c // just append it to the output
     89     } 
     90     return {output: out, attrs: attrs} // return both output and remaining attributes
     91   }
     92 
     93   function gophermapToHTML(gmap, chost, cport) {
     94     if(!cport) cport = 70
     95     if(!chost) chost = 'localhost'
     96     var lines = gmap.split(/\r?\n/), line, l = lines.length, i, lbr = '<br>',
     97         type, rest, desc, sel, host, port,
     98         iattrs = [], istyled, // attribute cache and styled infomessage placeholder
     99         output = '', pretag = 'pre', preflag = false
    100     for(i=0;i<l;i++) {
    101       line = lines[i]
    102       if(line === '.')
    103         break // immediately stop parsing on the . line
    104       if(line.indexOf('\t') == -1 && !line.startsWith('i')) // this also handles empty lines
    105         line = 'i' + line
    106       type = line[0] // type
    107       rest = line.substr(1).split('\t')
    108       desc = esc(rest[0]) // escaping description (obviously must not contain tabs)
    109       sel = rest.length > 1 ? rest[1] : ''      // selector
    110       host = rest.length > 2 ? rest[2] : ''     // hostname
    111       port = rest.length > 3 ? (0|rest[3]) : '' // port
    112       // selector defaults to the description only if there are no other fields
    113       if(!sel && rest.length < 2) sel = desc
    114       if(!host) host = chost  // host defaults to the current host
    115       if(!port) port = cport  // port defaults to the current port
    116       if(type == 'i') {  // information message - wrap them in pretag
    117         if(!preflag) {
    118           preflag = true
    119           output += '<' + pretag + '>'
    120         }
    121         istyled = terminalize(desc, iattrs)
    122         iattrs = istyled.attrs
    123         output += istyled.output + '\n'
    124       }
    125       else { //information messages ended, stop preformatting
    126         if(preflag) {
    127           preflag = false
    128           iattrs = [] // reset info styling attributes
    129           output += '</' + pretag + '>'
    130         }
    131         if(desc = desc.trim()) { // ignore empty descriptions
    132           if(type == '3') // error message
    133             output += '<span class=error>' + desc + '</span>' + lbr
    134           else if(type == '8') { // output the telnet:// link as is
    135             var tlink = 'telnet://' + (sel ? (sel+'@') : '') + host + (port ? (':'+port) : '') 
    136             output += desc + ': ' + esc(tlink) + lbr
    137           }
    138           else { // shape the link (internal hi:type|sel|host|port format unless it's an external URL)
    139             var deflink = 'hi:'+[type,sel,host,port].join('|'), link = deflink
    140             if(type == 'h' && sel.startsWith('URL:')) {
    141               link = sel.split(':').slice(1).join(':').trim()
    142               if(link.startsWith('javascript:')) // XSS protection
    143                 link = deflink
    144             }
    145             output += '<a href="' + encodeURI(link) + '">' + desc + '</a>' + lbr
    146           }
    147         }
    148       }
    149     }
    150     return output
    151   }
    152 
    153   // resource format: [type, selector, host, port]
    154   function loadHole(resource, input, successCb, errorCb) {
    155     var fname = resource[1].split('/').pop(), // as the most intelligent guess
    156         ext = fname.split('.').pop().toLowerCase(), // presumed extension
    157         extMappings = { // not including AVIF here because they aren't supported by Gecko 48 anyway
    158           'gif': 'image/gif', // including GIF though because nothing prevents using I instead of g
    159           'png': 'image/png',
    160           'svg': 'image/svg+xml',
    161           'webp': 'image/webp',
    162           'apng': 'image/apng',
    163           'jpg': 'image/jpeg',
    164           'jpeg': 'image/jpeg',
    165           'jfif': 'image/jpeg',
    166           'pjpeg': 'image/jpeg',
    167           'pjp': 'image/jpeg'
    168         }
    169     psGopherRequest(resource, input, function(rawdata, type) {
    170       if(type == '5' || type == 's' || type == ';' || type == 'd')
    171         type = '9' // remap unknown binary types to 9
    172       if(type == '9' || type == 'g' || type == 'I') { // binary file
    173         var ctype = 'application/octet-stream' // the default one if everything else fails
    174         // update content type
    175         if(type == 'g') // GIF-only resource type
    176           ctype = 'image/gif'
    177         else if(type == 'I') // attempt to guess the image MIME type by the extension
    178           ctype = extMappings[ext]
    179         var datablob = new Blob([rawdata], {type: ctype})
    180         successCb({
    181           content: datablob,
    182           contentType: ctype,
    183           contentName: fname,
    184           serviceMsg: type + ' ' + resource[1],
    185           updateAddr: false
    186         })
    187       }
    188       else { // assuming text content otherwise
    189         var output = (new TextDecoder).decode(rawdata), ctype = 'text/plain', // defaulting to type 0
    190             wo = '' // wrapper-friendly output
    191         if(type == '1' || type == '7') { // gophermap
    192           output = gophermapToHTML(output, resource[2], resource[3])
    193           ctype = 'text/html'
    194           wo = output
    195         }
    196         else wo = unphlow(output)
    197         successCb({
    198           content: output,
    199           contentWf: wo,
    200           contentType: ctype,
    201           serviceMsg: type + ' ' + resource[1]
    202         })
    203       }
    204     }, errorCb)
    205   }
    206 
    207   return {load: loadHole, render: gophermapToHTML}
    208 })(gopherRequest)