use markdown syntax for images
[teliva.git] / gemini.tlv
blob72fc3879645facd3e93c589cc44cbea39d1e56cb
1 # .tlv file generated by https://github.com/akkartik/teliva
2 # You may edit it if you are careful; however, you may see cryptic errors if you
3 # violate Teliva's assumptions.
5 # .tlv files are representations of Teliva programs. Teliva programs consist of
6 # sequences of definitions. Each definition is a table of key/value pairs. Keys
7 # and values are both strings.
9 # Lines in .tlv files always follow exactly one of the following forms:
10 # - comment lines at the top of the file starting with '#' at column 0
11 # - beginnings of definitions starting with '- ' at column 0, followed by a
12 #   key/value pair
13 # - key/value pairs consisting of '  ' at column 0, containing either a
14 #   spaceless value on the same line, or a multi-line value
15 # - multiline values indented by more than 2 spaces, starting with a '>'
17 # If these constraints are violated, Teliva may unceremoniously crash. Please
18 # report bugs at http://akkartik.name/contact
19 - __teliva_timestamp: original
20   str_helpers:
21     >-- some string helpers from http://lua-users.org/wiki/StringIndexing
22     >
23     >-- index characters using []
24     >getmetatable('').__index = function(str,i)
25     >  if type(i) == 'number' then
26     >    return str:sub(i,i)
27     >  else
28     >    return string[i]
29     >  end
30     >end
31     >
32     >-- ranges using (), selected bytes using {}
33     >getmetatable('').__call = function(str,i,j)
34     >  if type(i)~='table' then
35     >    return str:sub(i,j)
36     >  else
37     >    local t={}
38     >    for k,v in ipairs(i) do
39     >      t[k]=str:sub(v,v)
40     >    end
41     >    return table.concat(t)
42     >  end
43     >end
44     >
45     >-- iterate over an ordered sequence
46     >function q(x)
47     >  if type(x) == 'string' then
48     >    return x:gmatch('.')
49     >  else
50     >    return ipairs(x)
51     >  end
52     >end
53     >
54     >-- insert within string
55     >function string.insert(str1, str2, pos)
56     >  return str1:sub(1,pos)..str2..str1:sub(pos+1)
57     >end
58     >
59     >function string.remove(s, pos)
60     >  return s:sub(1,pos-1)..s:sub(pos+1)
61     >end
62     >
63     >function string.pos(s, sub)
64     >  return string.find(s, sub, 1, true)  -- plain=true to disable regular expressions
65     >end
66     >
67     >-- TODO: backport utf-8 support from Lua 5.3
68 - __teliva_timestamp: original
69   debugy:
70     >debugy = 5
71 - __teliva_timestamp: original
72   dbg:
73     >-- helper for debug by print; overlay debug information towards the right
74     >-- reset debugy every time you refresh screen
75     >function dbg(window, s)
76     >  local oldy = 0
77     >  local oldx = 0
78     >  oldy, oldx = window:getyx()
79     >  window:mvaddstr(debugy, 60, s)
80     >  debugy = debugy+1
81     >  window:mvaddstr(oldy, oldx, '')
82     >end
83 - __teliva_timestamp: original
84   check:
85     >function check(x, msg)
86     >  if x then
87     >    Window:addch('.')
88     >  else
89     >    print('F - '..msg)
90     >    print('  '..str(x)..' is false/nil')
91     >    teliva_num_test_failures = teliva_num_test_failures + 1
92     >    -- overlay first test failure on editors
93     >    if teliva_first_failure == nil then
94     >      teliva_first_failure = msg
95     >    end
96     >  end
97     >end
98 - __teliva_timestamp: original
99   check_eq:
100     >function check_eq(x, expected, msg)
101     >  if eq(x, expected) then
102     >    Window:addch('.')
103     >  else
104     >    print('F - '..msg)
105     >    print('  expected '..str(expected)..' but got '..str(x))
106     >    teliva_num_test_failures = teliva_num_test_failures + 1
107     >    -- overlay first test failure on editors
108     >    if teliva_first_failure == nil then
109     >      teliva_first_failure = msg
110     >    end
111     >  end
112     >end
113 - __teliva_timestamp: original
114   eq:
115     >function eq(a, b)
116     >  if type(a) ~= type(b) then return false end
117     >  if type(a) == 'table' then
118     >    if #a ~= #b then return false end
119     >    for k, v in pairs(a) do
120     >      if b[k] ~= v then
121     >        return false
122     >      end
123     >    end
124     >    for k, v in pairs(b) do
125     >      if a[k] ~= v then
126     >        return false
127     >      end
128     >    end
129     >    return true
130     >  end
131     >  return a == b
132     >end
133 - __teliva_timestamp: original
134   str:
135     >-- smarter tostring
136     >-- slow; used only for debugging
137     >function str(x)
138     >  if type(x) == 'table' then
139     >    local result = ''
140     >    result = result..#x..'{'
141     >    for k, v in pairs(x) do
142     >      result = result..str(k)..'='..str(v)..', '
143     >    end
144     >    result = result..'}'
145     >    return result
146     >  elseif type(x) == 'string' then
147     >    return '"'..x..'"'
148     >  end
149     >  return tostring(x)
150     >end
151 - __teliva_timestamp: original
152   find_index:
153     >function find_index(arr, x)
154     >  for n, y in ipairs(arr) do
155     >    if x == y then
156     >      return n
157     >    end
158     >  end
159     >end
160 - __teliva_timestamp: original
161   trim:
162     >function trim(s)
163     >  return s:gsub('^%s*', ''):gsub('%s*$', '')
164     >end
165 - __teliva_timestamp: original
166   split:
167     >function split(s, d)
168     >  result = {}
169     >  for match in (s..d):gmatch("(.-)"..d) do
170     >    table.insert(result, match);
171     >  end
172     >  return result
173     >end
174 - __teliva_timestamp: original
175   clear:
176     >function clear(lines)
177     >  while #lines > 0 do
178     >    table.remove(lines)
179     >  end
180     >end
181 - __teliva_timestamp: original
182   Window:
183     >Window = curses.stdscr()
184 - __teliva_timestamp: original
185   render_line:
186     >function render_line(window, y, line)
187     >  window:mvaddstr(y, 0, '')
188     >  for i=1,line:len() do
189     >    window:addstr(line[i])
190     >  end
191     >end
192 - __teliva_timestamp: original
193   render_link:
194     >function render_link(window, y, line)
195     >  local rendered_line = line:gsub('=>%s*%S*%s*', '')
196     >  if trim(rendered_line) == '' then
197     >    rendered_line = line
198     >  end
199     >  render_line(window, y, rendered_line)
200     >end
201 - __teliva_timestamp: original
202   state:
203     >state = {
204     >  lines={},
205     >  history={},
206     >  highlight_index=0,
207     >  source=false,  -- show source (link urls, etc.)
208     >}
209 - __teliva_timestamp: original
210   render_page:
211     >function render_page(window)
212     >  local y = 0
213     >  window:attron(curses.color_pair(6))
214     >  print(state.url)
215     >  window:attroff(curses.color_pair(6))
216     >  y = y+2
217     >--?   dbg(window, state.highlight_index)
218     >  for i, line in pairs(state.lines) do
219     >    if not state.source and line:find('=> ') == 1 then
220     >      if state.highlight_index == 0 or i == state.highlight_index then
221     >        -- highlighted link
222     >        state.highlight_index = i  -- TODO: ugly state update while rendering, just for first render after gemini_get
223     >        window:attron(curses.A_REVERSE)
224     >        render_link(window, y, line)
225     >        window:attroff(curses.A_REVERSE)
226     >      else
227     >        -- link
228     >        window:attron(curses.A_BOLD)
229     >        render_link(window, y, line)
230     >        window:attroff(curses.A_BOLD)
231     >      end
232     >    else
233     >      -- non-link
234     >      render_line(window, y, line)
235     >    end
236     >    y = y+1
237     >  end
238     >end
239 - __teliva_timestamp: original
240   render:
241     >function render(window, lines)
242     >  window:clear()
243     >  render_page(window, lines)
244     >  curses.curs_set(0)
245     >  window:refresh()
246     >end
247 - __teliva_timestamp: original
248   menu:
249     >menu = {
250     >  {'Enter', 'go to highlight'},
251     >  {'<-', 'back'},
252     >  {'^g', 'enter url'},
253     >  {'^u', 'view source'},
254     >}
255 - __teliva_timestamp: original
256   edit_line:
257     >function edit_line(window)
258     >  local result = ''
259     >  local cursor = 1
260     >  local screen_rows, screen_cols = window:getmaxyx()
261     >  menu = {
262     >    {'enter', 'submit'},
263     >    {'^g', 'cancel'},
264     >    {'^u', 'clear'},
265     >  }
266     >  while true do
267     >    window:mvaddstr(screen_rows-1, 9, '')
268     >    window:clrtoeol()
269     >    window:mvaddstr(screen_rows-1, 9, result)
270     >    window:attron(curses.A_REVERSE)
271     >    -- window:refresh()
272     >    local key = window:getch()
273     >    window:attrset(curses.A_NORMAL)
274     >    if key >= 32 and key < 127 then
275     >      local screen_rows, screen_cols = window:getmaxyx()
276     >      if #result < screen_cols then
277     >        result = result:insert(string.char(key), cursor-1)
278     >        cursor = cursor+1
279     >      end
280     >    elseif key == curses.KEY_LEFT then
281     >      if cursor > 1 then
282     >        cursor = cursor-1
283     >      end
284     >    elseif key == curses.KEY_RIGHT then
285     >      if cursor <= #result then
286     >        cursor = cursor+1
287     >      end
288     >    elseif key == curses.KEY_BACKSPACE then
289     >      if cursor > 1 then
290     >        cursor = cursor-1
291     >        result = result:remove(cursor)
292     >      end
293     >    elseif key == 21 then  -- ctrl-u
294     >      result = ''
295     >      cursor = 1
296     >    elseif key == 10 then  -- enter
297     >      return result
298     >    elseif key == 7 then  -- ctrl-g
299     >      return nil
300     >    end
301     >  end
302     >end
303 - __teliva_timestamp: original
304   is_link:
305     >function is_link(line)
306     >  return line:find('=>%s*%S*%s*') == 1
307     >end
308 - __teliva_timestamp: original
309   next_link:
310     >function next_link()
311     >  local new_index = state.highlight_index
312     >  while true do
313     >    new_index = new_index+1
314     >    if new_index > #state.lines then return end
315     >    if is_link(state.lines[new_index]) then break end
316     >  end
317     >  state.highlight_index = new_index
318     >end
319 - __teliva_timestamp: original
320   previous_link:
321     >function previous_link()
322     >  local new_index = state.highlight_index
323     >  while true do
324     >    new_index = new_index - 1
325     >    if new_index < 1 then return end
326     >    if is_link(state.lines[new_index]) then break end
327     >  end
328     >  state.highlight_index = new_index
329     >end
330 - __teliva_timestamp: original
331   update:
332     >function update(window)
333     >  local key = window:getch()
334     >  local screen_rows, screen_cols = window:getmaxyx()
335     >  if key == curses.KEY_DOWN then
336     >    next_link()
337     >  elseif key == curses.KEY_UP then
338     >    previous_link()
339     >  elseif key == curses.KEY_LEFT then
340     >    if #state.history > 1 then
341     >      table.remove(state.history)
342     >      gemini_get(table.remove(state.history))
343     >    end
344     >  elseif key == 21 then  -- ctrl-u
345     >    state.source = not state.source
346     >  elseif key == 10 then  -- enter
347     >    local s, e, new_url = string.find(state.lines[state.highlight_index], '=>%s*(%S*)')
348     >    gemini_get(url.absolute(state.url, new_url))
349     >  elseif key == 7 then  -- ctrl-g
350     >    window:mvaddstr(screen_rows-2, 0, '')
351     >    window:clrtoeol()
352     >    window:mvaddstr(screen_rows-1, 0, '')
353     >    window:clrtoeol()
354     >    window:mvaddstr(screen_rows-1, 5, 'go: ')
355     >    curses.curs_set(2)
356     >    local old_menu = menu
357     >    local new_url = edit_line(window)
358     >    menu = old_menu
359     >    if new_url then
360     >      state.url = new_url
361     >      gemini_get(new_url)
362     >    end
363     >    curses.curs_set(0)
364     >  end
365     >end
366 - __teliva_timestamp: original
367   init_colors:
368     >function init_colors()
369     >  for i=0,7 do
370     >    curses.init_pair(i, i, -1)
371     >  end
372     >  curses.init_pair(8, 7, 0)
373     >  curses.init_pair(9, 7, 1)
374     >  curses.init_pair(10, 7, 2)
375     >  curses.init_pair(11, 7, 3)
376     >  curses.init_pair(12, 7, 4)
377     >  curses.init_pair(13, 7, 5)
378     >  curses.init_pair(14, 7, 6)
379     >  curses.init_pair(15, -1, 15)
380     >end
381 - __teliva_timestamp: original
382   main:
383     >function main()
384     >  Window:clear()
385     >  Window:refresh()
386     >  init_colors()
387     >  local lines = {}
388     >  local url = ''
389     >  if #arg > 0 then
390     >    state.url = arg[1]
391     >    lines = gemini_get(state.url)
392     >  end
393     >  while true do
394     >    render(Window, lines)
395     >    update(Window, lines)
396     >  end
397     >end
398 - __teliva_timestamp: original
399   http_get:
400     >function http_get(url)
401     >  -- https://stackoverflow.com/questions/42445423/luasocket-serveraccept-timeout-tcp
402     >  local parsed_url = socket.url.parse(url)
403     >  local tcp = socket.tcp()
404     >  tcp:connect(parsed_url.host, 80)
405     >  tcp:send('GET / HTTP/1.1\r\n')
406     >  -- http requires the Host header
407     >  tcp:send(string.format('Host: %s\r\n', parsed_url.host))
408     >  tcp:send('\r\n')
409     >  -- tcp:receive('*a') doesn't seem to detect when a request is done
410     >  -- so we have to manage the size of the expected response
411     >  headers = {}
412     >  while true do
413     >    local s, status = tcp:receive()
414     >    if s == nil then break end
415     >    if s == '' then break end
416     >    local header, value = s:match('(.-): (.*)')
417     >    if header == nil then
418     >      print(s)
419     >    else
420     >      headers[header:lower()] = value
421     >      print(header, value)
422     >    end
423     >  end
424     >  local bytes_remaining = tonumber(headers['content-length'])
425     >  body = ''
426     >  while true do
427     >    local s, status = tcp:receive(bytes_remaining)
428     >    if s == nil then break end
429     >    body = body .. s
430     >    bytes_remaining = bytes_remaining - s:len()
431     >    if bytes_remaining <= 0 then break end
432     >  end
433     >  return body
434     >end
435 - __teliva_timestamp: original
436   https_get:
437     >-- http://notebook.kulchenko.com/programming/https-ssl-calls-with-lua-and-luasec
438     >function https_get(url)
439     >  local parsed_url = socket.url.parse(url)
440     >  local params = {
441     >    mode = 'client',
442     >    protocol = 'any',
443     >    verify = 'none',  -- I don't know what I'm doing
444     >    options = 'all',
445     >  }
446     >  local conn = socket.tcp()
447     >  conn:connect(parsed_url.host, parsed_url.port or 443)
448     >  conn, err = ssl.wrap(conn, params)
449     >  if conn == nil then
450     >      io.write(err)
451     >      os.exit(1)
452     >  end
453     >  conn:dohandshake()
454     >
455     >  conn:send(url .. "\r\n")
456     >  local line, err = conn:receive()
457     >  return line or err
458     >end
459 - __teliva_timestamp: original
460   parse_gemini_body:
461     >function parse_gemini_body(conn, type)
462     >  if type == 'text/gemini' then
463     >    while true do
464     >      local line, err = conn:receive()
465     >      if line == nil then break end
466     >      table.insert(state.lines, line)
467     >    end
468     >  elseif type:sub(1, 5) == 'text/' then
469     >    while true do
470     >      local line, err = conn:receive()
471     >      if line == nil then break end
472     >      table.insert(state.lines, line)
473     >    end
474     >  end
475     >end
476 - __teliva_timestamp: original
477   gemini_get:
478     >-- http://notebook.kulchenko.com/programming/https-ssl-calls-with-lua-and-luasec
479     >-- https://tildegit.org/solderpunk/gemini-demo-2
480     >-- returns an array of lines, containing either the body or just an error
481     >function gemini_get(url)
482     >  if url:find("://") == nil then
483     >    url = "gemini://" .. url
484     >  end
485     >  local parsed_url = socket.url.parse(url)
486     >  local params = {
487     >    mode = 'client',
488     >    protocol = 'any',
489     >    verify = 'none',  -- I don't know what I'm doing
490     >    options = 'all',
491     >  }
492     >  local conn = socket.tcp()
493     >  local conn2, err = conn:connect(parsed_url.host, parsed_url.port or 1965)
494     >  clear(state.lines)
495     >  state.highlight_index = 0  -- highlighted link not computed yet
496     >  if conn2 == nil then
497     >    table.insert(state.lines, err)
498     >    return
499     >  end
500     >  conn, err = ssl.wrap(conn, params)
501     >  if conn == nil then
502     >    table.insert(state.lines, err)
503     >    return
504     >  end
505     >  conn:dohandshake()
506     >  conn:send(url .. "\r\n")
507     >  local line, err = conn:receive()
508     >  if line == nil then
509     >    table.insert(state.lines, err)
510     >    return
511     >  end
512     >  local status, meta = line:match("(%S+) (%S+)")
513     >  if status[1] == '2' then
514     >    parse_gemini_body(conn, meta)
515     >    state.url = url
516     >    table.insert(state.history, url)
517     >  elseif status[1] == '3' then
518     >    gemini_get(socket.url.absolute(url, meta))
519     >  elseif status[1] == '4' or line[1] == '5' then
520     >    table.insert(state.lines, 'Error: '..meta)
521     >  else
522     >    table.insert(state.lines, 'invalid response from server: '..line)
523     >  end
524     >end
525 - __teliva_timestamp:
526     >Thu Feb 17 20:04:42 2022
527   doc:blurb:
528     >A bare-bones browser for the Gemini protocol
529     >
530     >https://gemini.circumlunar.space
531     >
532     >A couple of good pages to try it out with:
533     >  $ src/teliva gemini.tlv gemini.circumlunar.space
534     >  $ src/teliva gemini.tlv gemini.conman.org
535     >  $ src/teliva gemini.tlv gemini.susa.net/cgi-bin/links.lua  # shows 20 random links from Gemini space