about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2025-04-09 02:38:04 +0200
committerbptato <nincsnevem662@gmail.com>2025-04-11 19:04:56 +0200
commitc908f05f03bac0cbccd14ab13ea8d0df8b087611 (patch)
tree7b2999729b66c6179340abdcf2efd76c065b201c
parent581aa021d4b30075d01b892e78bf4757e8942c4e (diff)
downloadchawan-c908f05f03bac0cbccd14ab13ea8d0df8b087611.tar.gz
config: unify table arrays and tables
[[siteconf]] now just means [siteconf.0], etc.  So you can now override
parts of default siteconfs/omnirules, e.g. to change the Google search
substitute-url, etc.

To celebrate this, I've added some more default search engines:
* wk: -> Wikipedia
* wd: -> Wiktionary
* ms: -> Marginalia Search

These can be replaced by setting e.g. omnirule.wk = {}, etc.

Also, siteconf = {} can be used to clear pre-defined siteconfs.
This is an unfortunate deviation in semantics from TOML, but in practice
the way it worked before didn't match the spec either, so at least it
is now consistent.
-rw-r--r--doc/cha-config.549
-rw-r--r--doc/config.md49
-rw-r--r--res/config.toml27
-rw-r--r--src/config/config.nim82
-rw-r--r--src/config/toml.nim95
-rw-r--r--src/local/pager.nim6
-rw-r--r--src/main.nim20
-rw-r--r--todo7
8 files changed, 195 insertions, 140 deletions
diff --git a/doc/cha-config.5 b/doc/cha-config.5
index 066d4bd2..af553073 100644
--- a/doc/cha-config.5
+++ b/doc/cha-config.5
@@ -5,21 +5,24 @@
 .SH Configuration of Chawan
 Chawan supports configuration of various options like keybindings, user
 stylesheets, site preferences, etc.
-The configuration format is almost toml, with the following exceptions:
+The configuration format is similar to toml, with the following
+exceptions:
 .IP \[bu] 2
 Inline tables may span across multiple lines.
 .IP \[bu] 2
-Table arrays can be cleared by setting a variable by the same to the
-empty array.
-This allows users to disable default table array rules.
-.PP
-Example:
-.IP
-.EX
-omnirule = [] # note: this must be placed at the beginning of the file.
-
-[[omnirule]] # this is legal. all default omni\-rules are now disabled.
-.EE
+Regular tables (\f[CR][table]\f[R]) and inline tables
+(\f[CR]table = {}\f[R]) have different semantics.
+The first is additive, meaning old values are not removed.
+The second is destructive, and clears all definitions in the table
+specified.
+.IP \[bu] 2
+For backwards compatibility, \f[CR]table = []\f[R] is equivalent to
+\f[CR]table = {}\f[R].
+.IP \[bu] 2
+\f[CR][[table\-array]]\f[R] is sugar for \f[CR][table\-array.n]\f[R],
+where \f[CR]n\f[R] is the number of declared table arrays.
+For example, you can declare anonymous siteconfs using the syntax
+\f[CR][[siteconf]]\f[R].
 .PP
 The canonical configuration file path is \[ti]/.chawan/config.toml, but
 the search path accommodates XDG basedirs as well:
@@ -956,7 +959,9 @@ T}
 .SS Omnirule
 The omni\-bar (by default opened with C\-l) can be used to perform
 searches using omni\-rules.
-These are to be placed in the table array \f[CR][[omnirule]]\f[R].
+These are to be specified as sub\-keys to table \f[CR][omnirule]\f[R].
+(The sub\-key itself is ignored; you can use anything as long it
+doesn\[cq]t conflict with other keys.)
 .PP
 Examples:
 .IP
@@ -964,11 +969,13 @@ Examples:
 # Search using DuckDuckGo Lite.
 # (This rule is included in the default config, although C\-k now invokes
 # Google search.)
-[[omnirule]]
+[omnirule.ddg]
 match = \[aq]\[ha]ddg:\[aq]
 substitute\-url = \[aq](x) => \[dq]https://lite.duckduckgo.com/lite/?kp=\-1&kd=\-1&q=\[dq] + encodeURIComponent(x.split(\[dq]:\[dq]).slice(1).join(\[dq]:\[dq]))\[aq]
 
 # Search using Wikipedia, Firefox\-style.
+# The [[omnirule]] syntax introduces an anonymous omnirule; it is
+# equivalent to the named one.
 [[omnirule]]
 match = \[aq]\[ha]\[at]wikipedia\[aq]
 substitute\-url = \[aq](x) => \[dq]https://en.wikipedia.org/wiki/Special:Search?search=\[dq] + encodeURIComponent(x.replace(/\[at]wikipedia/, \[dq]\[dq]))\[aq]
@@ -1011,7 +1018,9 @@ T}
 .TE
 .SS Siteconf
 Configuration options can be specified for individual sites.
-Entries are to be placed in the table array \f[CR][[siteconf]]\f[R].
+Entries are to be specified as sub\-keys to table \f[CR][siteconf]\f[R].
+(The sub\-key itself is ignored; you can use anything as long it
+doesn\[cq]t conflict with other keys.)
 .PP
 Most siteconf options can also be specified globally; see the
 \[lq]overrides\[rq] field.
@@ -1020,12 +1029,12 @@ Examples:
 .IP
 .EX
 # Enable cookies on the orange website for log\-in.
-[[siteconf]]
+[siteconf.hn]
 url = \[aq]https://news\[rs].ycombinator\[rs].com/.*\[aq]
 cookie = true
 
 # Redirect npr.org to text.npr.org.
-[[siteconf]]
+[siteconf.npr]
 host = \[aq](www\[rs].)?npr\[rs].org\[aq]
 rewrite\-url = \[aq]\[aq]\[aq]
 (x) => {
@@ -1037,18 +1046,20 @@ x.pathname = s.at(s.length > 2 ? \-2 : 1);
 \[aq]\[aq]\[aq]
 
 # Allow cookie sharing on *sr.ht domains.
-[[siteconf]]
+[siteconf.sr\-ht]
 host = \[aq](.*\[rs].)?sr\[rs].ht\[aq] # either \[aq]something.sr.ht\[aq] or \[aq]sr.ht\[aq]
 cookie = true # enable cookies (read\-only; use \[dq]save\[dq] to persist them)
 share\-cookie\-jar = \[aq]sr.ht\[aq] # use the cookie jar of \[aq]sr.ht\[aq] for all matched hosts
 
 # Use the \[dq]vector\[dq] skin on Wikipedia.
+# The [[siteconf]] syntax introduces an anonymous siteconf; it is
+# equivalent to the above ones.
 [[siteconf]]
 url = \[aq]\[ha]https?://[a\-z]+\[rs].wikipedia\[rs].org/wiki/(?!.*useskin=.*)\[aq]
 rewrite\-url = \[aq]x => x.searchParams.append(\[dq]useskin\[dq], \[dq]vector\[dq])\[aq]
 
 # Make imgur send us images.
-[[siteconf]]
+[siteconf.imgur]
 host = \[aq](i\[rs].)?imgur\[rs].com\[aq]
 default\-headers = {
 User\-Agent = \[dq]Mozilla/5.0 chawan\[dq],
diff --git a/doc/config.md b/doc/config.md
index 75905e8c..3cb68e86 100644
--- a/doc/config.md
+++ b/doc/config.md
@@ -5,19 +5,19 @@ MANOFF -->
 # Configuration of Chawan
 
 Chawan supports configuration of various options like keybindings, user
-stylesheets, site preferences, etc. The configuration format is almost
-toml, with the following exceptions:
+stylesheets, site preferences, etc. The configuration format is similar
+to toml, with the following exceptions:
 
 * Inline tables may span across multiple lines.
-* Table arrays can be cleared by setting a variable by the same to the
-  empty array. This allows users to disable default table array rules.
-
-Example:
-```
-omnirule = [] # note: this must be placed at the beginning of the file.
-
-[[omnirule]] # this is legal. all default omni-rules are now disabled.
-```
+* Regular tables (`[table]`) and inline tables (`table = {}`) have
+  different semantics.  The first is additive, meaning old values are
+  not removed.  The second is destructive, and clears all definitions in
+  the table specified.
+* For backwards compatibility, `table = []` is equivalent to
+  `table = {}`.
+* `[[table-array]]` is sugar for `[table-array.n]`, where `n` is the
+  number of declared table arrays.  For example, you can declare
+  anonymous siteconfs using the syntax `[[siteconf]]`.
 
 The canonical configuration file path is ~/.chawan/config.toml, but
 the search path accommodates XDG basedirs as well:
@@ -760,19 +760,24 @@ protocol.</td>
 
 ## Omnirule
 
-The omni-bar (by default opened with C-l) can be used to perform searches using
-omni-rules. These are to be placed in the table array `[[omnirule]]`.
+The omni-bar (by default opened with C-l) can be used to perform
+searches using omni-rules.  These are to be specified as sub-keys to table
+`[omnirule]`.  (The sub-key itself is ignored; you can use anything as
+long it doesn't conflict with other keys.)
 
 Examples:
+
 ```
 # Search using DuckDuckGo Lite.
 # (This rule is included in the default config, although C-k now invokes
 # Google search.)
-[[omnirule]]
+[omnirule.ddg]
 match = '^ddg:'
 substitute-url = '(x) => "https://lite.duckduckgo.com/lite/?kp=-1&kd=-1&q=" + encodeURIComponent(x.split(":").slice(1).join(":"))'
 
 # Search using Wikipedia, Firefox-style.
+# The [[omnirule]] syntax introduces an anonymous omnirule; it is
+# equivalent to the named one.
 [[omnirule]]
 match = '^@wikipedia'
 substitute-url = '(x) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(x.replace(/@wikipedia/, ""))'
@@ -808,8 +813,10 @@ returned, it will be parsed instead of the old one.</td>
 
 ## Siteconf
 
-Configuration options can be specified for individual sites. Entries are
-to be placed in the table array `[[siteconf]]`.
+Configuration options can be specified for individual sites.  Entries
+are to be specified as sub-keys to table `[siteconf]`.  (The sub-key
+itself is ignored; you can use anything as long it doesn't conflict with
+other keys.)
 
 Most siteconf options can also be specified globally; see the
 "overrides" field.
@@ -817,12 +824,12 @@ Most siteconf options can also be specified globally; see the
 Examples:
 ```
 # Enable cookies on the orange website for log-in.
-[[siteconf]]
+[siteconf.hn]
 url = 'https://news\.ycombinator\.com/.*'
 cookie = true
 
 # Redirect npr.org to text.npr.org.
-[[siteconf]]
+[siteconf.npr]
 host = '(www\.)?npr\.org'
 rewrite-url = '''
 (x) => {
@@ -834,18 +841,20 @@ rewrite-url = '''
 '''
 
 # Allow cookie sharing on *sr.ht domains.
-[[siteconf]]
+[siteconf.sr-ht]
 host = '(.*\.)?sr\.ht' # either 'something.sr.ht' or 'sr.ht'
 cookie = true # enable cookies (read-only; use "save" to persist them)
 share-cookie-jar = 'sr.ht' # use the cookie jar of 'sr.ht' for all matched hosts
 
 # Use the "vector" skin on Wikipedia.
+# The [[siteconf]] syntax introduces an anonymous siteconf; it is
+# equivalent to the above ones.
 [[siteconf]]
 url = '^https?://[a-z]+\.wikipedia\.org/wiki/(?!.*useskin=.*)'
 rewrite-url = 'x => x.searchParams.append("useskin", "vector")'
 
 # Make imgur send us images.
-[[siteconf]]
+[siteconf.imgur]
 host = '(i\.)?imgur\.com'
 default-headers = {
 	User-Agent = "Mozilla/5.0 chawan",
diff --git a/res/config.toml b/res/config.toml
index d0a1a980..26d23485 100644
--- a/res/config.toml
+++ b/res/config.toml
@@ -107,16 +107,37 @@ force-lines = false
 force-pixels-per-column = false
 force-pixels-per-line = false
 
-[[omnirule]]
+[omnirule.ddg]
 match = '^ddg:'
 substitute-url = 'x => "https://lite.duckduckgo.com/lite/?kp=-1&kd=-1&q=" + encodeURIComponent(x.split(":").slice(1).join(":"))'
 
-[[omnirule]]
+[omnirule.go]
 match = '^go:'
 substitute-url = 'x => `https://www.google.com/search?gbv=1&ucbcb=1&oe=UTF-8&q=${x.split(":").slice(1).join(":")}`'
 
+[omnirule.wk]
+match = '^wk:'
+substitute-url = '''
+x => "https://en.wikipedia.org/wiki/Special:Search?search=" +
+	encodeURIComponent(x.split(":").slice(1).join(":"))
+'''
+
+[omnirule.wd]
+match = '^wd:'
+substitute-url = '''
+x => "https://en.wiktionary.org/w/index.php?title=Special:Search&search=" +
+	encodeURIComponent(x.split(":").slice(1).join(":"))
+'''
+
+[omnirule.ms]
+match = '^ms:'
+substitute-url = '''
+x => "https://marginalia-search.com/search?query=" +
+	encodeURIComponent(x.split(":").slice(1).join(":"));
+'''
+
 # strip tracking
-[[siteconf]]
+[siteconf.google-com]
 url = '^https?://(www\.)?google\.com'
 rewrite-url = '''
 x => {
diff --git a/src/config/config.nim b/src/config/config.nim
index 5ca8cec1..c17ceae4 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -88,7 +88,7 @@ type
     userStyle*: Option[StyleString]
 
   OmniRule* = ref object
-    match*: Regex
+    match*: Option[Regex]
     substituteUrl*: Option[JSValueFunction]
 
   StartConfig = object
@@ -183,8 +183,8 @@ type
     userStyle*: StyleString #TODO getset
 
   Config* = ref object
-    jsctx*: JSContext
     jsvfns*: seq[JSValueFunction]
+    arraySeen*: TableRef[string, int] # table arrays seen
     dir* {.jsget.}: string
     `include` {.jsget.}: seq[ChaPathResolved]
     start* {.jsget.}: StartConfig
@@ -198,8 +198,8 @@ type
     display* {.jsget.}: DisplayConfig
     #TODO getset
     protocol*: Table[string, ProtocolConfig]
-    siteconf*: seq[SiteConfig]
-    omnirule*: seq[OmniRule]
+    siteconf*: OrderedTable[string, SiteConfig]
+    omnirule*: OrderedTable[string, OmniRule]
     cmd*: CommandConfig
     page* {.jsget.}: ActionMap
     line* {.jsget.}: ActionMap
@@ -325,6 +325,7 @@ proc readUserStylesheet(outs: var string; dir, file: string): Err[string] =
   ok()
 
 type ConfigParser = object
+  jsctx: JSContext
   config: Config
   dir: string
   warnings: seq[string]
@@ -365,12 +366,12 @@ proc parseConfigValue(ctx: var ConfigParser; x: var CSSConfig; v: TomlValue;
   k: string): Err[string]
 proc parseConfigValue[U; V](ctx: var ConfigParser; x: var Table[U, V];
   v: TomlValue; k: string): Err[string]
+proc parseConfigValue[U; V](ctx: var ConfigParser; x: var OrderedTable[U, V];
+  v: TomlValue; k: string): Err[string]
 proc parseConfigValue[U; V](ctx: var ConfigParser; x: var TableRef[U, V];
   v: TomlValue; k: string): Err[string]
 proc parseConfigValue[T](ctx: var ConfigParser; x: var set[T]; v: TomlValue;
   k: string): Err[string]
-proc parseConfigValue(ctx: var ConfigParser; x: var TomlTable; v: TomlValue;
-  k: string): Err[string]
 proc parseConfigValue(ctx: var ConfigParser; x: var Regex; v: TomlValue;
   k: string): Err[string]
 proc parseConfigValue(ctx: var ConfigParser; x: var URL; v: TomlValue;
@@ -407,8 +408,10 @@ proc typeCheck(v: TomlValue; t: set[TomlValueType]; k: string): Err[string] =
 proc parseConfigValue(ctx: var ConfigParser; x: var object; v: TomlValue;
     k: string): Err[string] =
   ?typeCheck(v, tvtTable, k)
+  if v.tab.clear:
+    x = default(typeof(x))
   for fk, fv in x.fieldPairs:
-    when typeof(fv) isnot JSContext|seq[JSValueFunction]:
+    when fk notin ["jsvfns", "arraySeen", "dir"]:
       let kebabk = camelToKebabCase(fk)
       if kebabk in v:
         let kkk = if k != "":
@@ -420,29 +423,39 @@ proc parseConfigValue(ctx: var ConfigParser; x: var object; v: TomlValue;
 
 proc parseConfigValue(ctx: var ConfigParser; x: var ref object; v: TomlValue;
     k: string): Err[string] =
-  new(x)
+  ?typeCheck(v, tvtTable, k)
+  if x == nil:
+    new(x)
   ctx.parseConfigValue(x[], v, k)
 
 proc parseConfigValue[U, V](ctx: var ConfigParser; x: var Table[U, V];
     v: TomlValue; k: string): Err[string] =
   ?typeCheck(v, tvtTable, k)
-  x.clear()
+  if v.tab.clear:
+    x.clear()
+  for kk, vv in v:
+    let kkk = k & "[" & kk & "]"
+    ?ctx.parseConfigValue(x.mgetOrPut(kk, default(V)), vv, kkk)
+  ok()
+
+proc parseConfigValue[U, V](ctx: var ConfigParser; x: var OrderedTable[U, V];
+    v: TomlValue; k: string): Err[string] =
+  ?typeCheck(v, tvtTable, k)
+  if v.tab.clear:
+    x.clear()
   for kk, vv in v:
-    var y: V
     let kkk = k & "[" & kk & "]"
-    ?ctx.parseConfigValue(y, vv, kkk)
-    x[kk] = y
+    ?ctx.parseConfigValue(x.mgetOrPut(kk, default(V)), vv, kkk)
   ok()
 
 proc parseConfigValue[U, V](ctx: var ConfigParser; x: var TableRef[U, V];
     v: TomlValue; k: string): Err[string] =
   ?typeCheck(v, tvtTable, k)
-  x = TableRef[U, V]()
+  if v.tab.clear or x == nil:
+    x = TableRef[U, V]()
   for kk, vv in v:
-    var y: V
     let kkk = k & "[" & kk & "]"
-    ?ctx.parseConfigValue(y, vv, kkk)
-    x[kk] = y
+    ?ctx.parseConfigValue(x.mgetOrPut(kk, default(V)), vv, kkk)
   ok()
 
 proc parseConfigValue(ctx: var ConfigParser; x: var bool; v: TomlValue;
@@ -471,20 +484,12 @@ proc parseConfigValue[T](ctx: var ConfigParser; x: var seq[T]; v: TomlValue;
     ?ctx.parseConfigValue(y, v, k)
     x = @[y]
   else:
-    if not v.ad:
-      x.setLen(0)
     for i in 0 ..< v.a.len:
       var y: T
       ?ctx.parseConfigValue(y, v.a[i], k & "[" & $i & "]")
       x.add(y)
   ok()
 
-proc parseConfigValue(ctx: var ConfigParser; x: var TomlTable; v: TomlValue;
-    k: string): Err[string] =
-  ?typeCheck(v, {tvtTable}, k)
-  x = v.tab
-  ok()
-
 proc parseConfigValue(ctx: var ConfigParser; x: var Charset; v: TomlValue;
     k: string): Err[string] =
   ?typeCheck(v, tvtString, k)
@@ -649,10 +654,10 @@ proc parseConfigValue(ctx: var ConfigParser; x: var URL; v: TomlValue;
 proc parseConfigValue(ctx: var ConfigParser; x: var JSValueFunction;
     v: TomlValue; k: string): Err[string] =
   ?typeCheck(v, tvtString, k)
-  let fun = ctx.config.jsctx.eval(v.s, "<config>", JS_EVAL_TYPE_GLOBAL)
+  let fun = ctx.jsctx.eval(v.s, "<config>", JS_EVAL_TYPE_GLOBAL)
   if JS_IsException(fun):
-    return err(k & ": " & ctx.config.jsctx.getExceptionMsg())
-  if not JS_IsFunction(ctx.config.jsctx, fun):
+    return err(k & ": " & ctx.jsctx.getExceptionMsg())
+  if not JS_IsFunction(ctx.jsctx, fun):
     return err(k & ": not a function")
   x = JSValueFunction(fun: fun)
   ctx.config.jsvfns.add(x) # so we can clean it up on exit
@@ -806,12 +811,16 @@ proc parseConfigValue(ctx: var ConfigParser; x: var DeprecatedStyleString;
   ctx.parseConfigValue(string(x), v, k)
 
 proc parseConfig*(config: Config; dir: string; buf: openArray[char];
-  warnings: var seq[string]; name = "<input>"; laxnames = false): Err[string]
+  warnings: var seq[string]; jsctx: JSContext; name: string;
+  laxnames = false): Err[string]
 
 proc parseConfig(config: Config; dir: string; t: TomlValue;
-    warnings: var seq[string]): Err[string] =
-  var ctx = ConfigParser(config: config, dir: dir)
+    warnings: var seq[string]; jsctx: JSContext): Err[string] =
+  var ctx = ConfigParser(config: config, dir: dir, jsctx: jsctx)
   ?ctx.parseConfigValue(config[], t, "")
+  for name, value in config.omnirule:
+    if value.match.isNone:
+      return err("omnirule." & name & ": missing match regex")
   #TODO: for omnirule/siteconf, check if substitution rules are specified?
   while config.`include`.len > 0:
     #TODO: warn about recursive includes
@@ -821,17 +830,17 @@ proc parseConfig(config: Config; dir: string; t: TomlValue;
       let ps = newPosixStream(s)
       if ps == nil:
         return err("include file not found: " & s)
-      ?config.parseConfig(dir, ps.readAll(), warnings)
+      ?config.parseConfig(dir, ps.readAll(), warnings, jsctx, s.afterLast('/'))
       ps.sclose()
   warnings.add(ctx.warnings)
   ok()
 
 proc parseConfig*(config: Config; dir: string; buf: openArray[char];
-    warnings: var seq[string]; name = "<input>"; laxnames = false):
-    Err[string] =
-  let toml = parseToml(buf, dir / name, laxnames)
+    warnings: var seq[string]; jsctx: JSContext; name: string;
+    laxnames = false): Err[string] =
+  let toml = parseToml(buf, dir / name, laxnames, config.arraySeen)
   if toml.isSome:
-    return config.parseConfig(dir, toml.get, warnings)
+    return config.parseConfig(dir, toml.get, warnings, jsctx)
   return err("Fatal error: failed to parse config\n" & toml.error)
 
 proc getNormalAction*(config: Config; s: string): string =
@@ -868,8 +877,7 @@ proc openConfig*(dir: var string; override: Option[string];
   return newPosixStream(dir / "config.toml")
 
 # called after parseConfig returns
-proc initCommands*(config: Config): Err[string] =
-  let ctx = config.jsctx
+proc initCommands*(ctx: JSContext; config: Config): Err[string] =
   let obj = JS_NewObject(ctx)
   defer: JS_FreeValue(ctx, obj)
   if JS_IsException(obj):
diff --git a/src/config/toml.nim b/src/config/toml.nim
index bf92d39c..6842d7fc 100644
--- a/src/config/toml.nim
+++ b/src/config/toml.nim
@@ -1,3 +1,17 @@
+# TOML parser.
+#
+# Note that while it says TOML on the tin, the actual configuration
+# language only superficially resembles it.  In particular, this dialect
+# has a) strict ordering requirements, b) no real distinction between
+# table arrays and tables, c) a distinction between inline tables and
+# regular tables.  For example, `table = {}` can be used to clear
+# a table.
+#
+# The reason for this is that TOML is fundamentally unsuitable for
+# layered configs, but we're stuck with it for historical reasons.
+# One day I hope to come up with a better config language, but migration
+# will be painful...
+
 import std/options
 import std/tables
 import std/times
@@ -24,8 +38,9 @@ type
     line: int
     root: TomlTable
     node: TomlNode
+    arraySeen: TableRef[string, int]
     currkey: seq[string]
-    tarray: bool
+    warnings: seq[string]
     laxnames: bool
 
   TomlValue* = ref object
@@ -42,7 +57,6 @@ type
       tab*: TomlTable
     of tvtArray:
       a*: seq[TomlValue]
-      ad*: bool
 
   TomlNode = ref object of RootObj
     comment: string
@@ -52,6 +66,7 @@ type
     value*: TomlValue
 
   TomlTable* = ref object of TomlNode
+    clear*: bool
     key: seq[string]
     nodes: seq[TomlNode]
     map: OrderedTable[string, TomlValue]
@@ -99,11 +114,11 @@ func `$`*(val: TomlValue): string =
   of tvtTable:
     result = $val.t
   of tvtArray:
-    #TODO if ad table array probably
     result = "["
-    for it in val.a:
+    for i, it in val.a.mypairs:
+      if i > 0:
+        result &= ','
       result &= $it
-      result &= ','
     result &= ']'
 
 func `[]`*(val: TomlValue; key: string): TomlValue =
@@ -244,29 +259,34 @@ proc consumeBare(state: var TomlParser; buf: openArray[char]; c: char):
 proc flushLine(state: var TomlParser): Err[TomlError] =
   if state.node != nil:
     if state.node of TomlKVPair:
+      let node = TomlKVPair(state.node)
       var i = 0
-      let keys = state.currkey & TomlKVPair(state.node).key
+      let keys = state.currkey & node.key
       var table = state.root
       while i < keys.len - 1:
-        if keys[i] in table.map:
-          let node = table.map[keys[i]]
+        let node = table.map.getOrDefault(keys[i])
+        if node != nil:
           if node.t == tvtTable:
             table = node.tab
-          elif node.t == tvtArray:
-            assert state.tarray
-            table = node.a[^1].tab
           else:
-            let s = keys.join('.')
+            let s = keys.toOpenArray(0, i).join('.')
             return state.err("re-definition of node " & s)
         else:
           let node = TomlTable()
           table.map[keys[i]] = TomlValue(t: tvtTable, tab: node)
           table = node
         inc i
-      if keys[i] in table.map:
+      let value = node.value
+      if i == 0 and value.t == tvtArray and value.a.len == 0:
+        # old delete syntax
+        let s = keys.join('.')
+        state.arraySeen.del(s)
+        table.clear = true
+      elif keys[i] in table.map:
         return state.err("re-definition of node " & keys.join('.'))
-      table.map[keys[i]] = TomlKVPair(state.node).value
-      table.nodes.add(state.node)
+      else:
+        table.map[keys[i]] = value
+        table.nodes.add(state.node)
     state.node = nil
   inc state.line
   return ok()
@@ -320,19 +340,26 @@ proc consumeKey(state: var TomlParser; buf: openArray[char]):
 proc consumeTable(state: var TomlParser; buf: openArray[char]):
     Result[TomlTable, TomlError] =
   let res = TomlTable()
+  var tarray = false
   while state.has(buf):
     let c = state.peek(buf, 0)
     case c
     of ' ', '\t': discard state.consume(buf)
-    of '\n': return ok(res)
+    of '\n':
+      if tarray:
+        return state.err("missing ] at table array key's end")
+      return ok(res)
     of ']':
-      if state.tarray:
+      if tarray:
         discard state.consume(buf)
+        let s = res.key.join('.')
+        inc state.arraySeen.mgetOrPut(s, 0)
+        res.key.add($state.arraySeen.getOrDefault(s))
         return ok(res)
       else:
         return state.err("redundant ] character after key")
     of '[':
-      state.tarray = true
+      tarray = true
       discard state.consume(buf)
     of '"', '\'':
       res.key = ?state.consumeKey(buf)
@@ -351,29 +378,7 @@ proc consumeNoState(state: var TomlParser; buf: openArray[char]):
     of ' ', '\t': discard
     of '[':
       discard state.consume(buf)
-      state.tarray = false
       let table = ?state.consumeTable(buf)
-      if state.tarray:
-        var node = state.root
-        for i in 0 ..< table.key.high:
-          if table.key[i] in node.map:
-            node = node.map[table.key[i]].tab
-          else:
-            let t2 = TomlTable()
-            node.map[table.key[i]] = TomlValue(t: tvtTable, tab: t2)
-            node = t2
-        if table.key[^1] in node.map:
-          var last = node.map[table.key[^1]]
-          if last.t != tvtArray:
-            let key = table.key.join('.')
-            return state.err("re-definition of node " & key &
-              " as table array (was " & $last.t & ")")
-          let val = TomlValue(t: tvtTable, tab: table)
-          last.a.add(val)
-        else:
-          let val = TomlValue(t: tvtTable, tab: table)
-          let last = TomlValue(t: tvtArray, a: @[val], ad: true)
-          node.map[table.key[^1]] = last
       state.currkey = table.key
       state.node = table
       return ok(false)
@@ -486,7 +491,8 @@ proc consumeArray(state: var TomlParser; buf: openArray[char]): TomlResult =
 
 proc consumeInlineTable(state: var TomlParser; buf: openArray[char]):
     TomlResult =
-  let res = TomlValue(t: tvtTable, tab: TomlTable())
+  state.arraySeen.del(state.currkey.join('.'))
+  let res = TomlValue(t: tvtTable, tab: TomlTable(clear: true))
   var key: seq[string] = @[]
   var haskey = false
   var val: TomlValue = nil
@@ -565,13 +571,14 @@ proc consumeValue(state: var TomlParser; buf: openArray[char]): TomlResult =
     return ok(TomlValue(t: tvtString, s: ""))
   return state.err("unexpected end of file")
 
-proc parseToml*(buf: openArray[char]; filename = "<input>"; laxnames = false):
-    TomlResult =
+proc parseToml*(buf: openArray[char]; filename: string; laxnames: bool;
+    arraySeen: TableRef[string, int]): TomlResult =
   var state = TomlParser(
     line: 1,
     root: TomlTable(),
     filename: filename,
-    laxnames: laxnames
+    laxnames: laxnames,
+    arraySeen: arraySeen
   )
   while state.has(buf):
     if ?state.consumeNoState(buf):
diff --git a/src/local/pager.nim b/src/local/pager.nim
index f8073e58..e84dc060 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -1863,7 +1863,7 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
   )
   var cookieJarId = url.host
   let surl = $url
-  for sc in pager.config.siteconf:
+  for sc in pager.config.siteconf.values:
     if sc.url.isSome and not sc.url.get.match(surl):
       continue
     elif sc.host.isSome and not sc.host.get.match(host):
@@ -2005,8 +2005,8 @@ proc gotoURL(pager: Pager; request: Request; prevurl = none(URL);
     return nil
 
 proc omniRewrite(pager: Pager; s: string): string =
-  for rule in pager.config.omnirule:
-    if rule.match.match(s):
+  for rule in pager.config.omnirule.values:
+    if rule.match.get.match(s):
       let fun = rule.substituteUrl.get
       let ctx = pager.jsctx
       var arg0 = ctx.toJS(s)
diff --git a/src/main.nim b/src/main.nim
index ff9ae25e..75166c34 100644
--- a/src/main.nim
+++ b/src/main.nim
@@ -5,6 +5,7 @@ when NimMajor < 2:
 import std/options
 import std/os
 import std/posix
+import std/tables
 
 import chagashi/charset
 import config/chapath
@@ -210,27 +211,30 @@ proc parse(ctx: var ParamParseContext) =
 const defaultConfig = staticRead"res/config.toml"
 
 proc initConfig(ctx: ParamParseContext; config: Config;
-    warnings: var seq[string]): Err[string] =
+    warnings: var seq[string]; jsctx: JSContext): Err[string] =
   let ps = openConfig(config.dir, ctx.configPath, warnings)
   if ps == nil and ctx.configPath.isSome:
     # The user specified a non-existent config file.
     return err("Failed to open config file " & ctx.configPath.get)
   putEnv("CHA_DIR", config.dir)
-  ?config.parseConfig("res", defaultConfig, warnings)
+  ?config.parseConfig("res", defaultConfig, warnings, jsctx, "res/config.toml")
   when defined(debug):
     if (let ps = newPosixStream(getCurrentDir() / "res/config.toml");
         ps != nil):
-      ?config.parseConfig(getCurrentDir(), ps.readAll(), warnings)
+      ?config.parseConfig(getCurrentDir(), ps.readAll(), warnings, jsctx,
+        "res/config.toml")
       ps.sclose()
   if ps != nil:
     let src = ps.readAllOrMmap()
-    ?config.parseConfig(config.dir, src.toOpenArray(), warnings)
+    ?config.parseConfig(config.dir, src.toOpenArray(), warnings, jsctx,
+      "config.toml")
     deallocMem(src)
     ps.sclose()
   for opt in ctx.opts:
-    ?config.parseConfig(getCurrentDir(), opt, warnings, laxnames = true)
+    ?config.parseConfig(getCurrentDir(), opt, warnings, jsctx, "<input>",
+      laxnames = true)
   config.css.stylesheet &= ctx.stylesheet
-  ?config.initCommands()
+  ?jsctx.initCommands(config)
   isCJKAmbiguous = config.display.doubleWidthAmbiguous
   return ok()
 
@@ -251,8 +255,8 @@ proc main() =
   let jsrt = newJSRuntime()
   let jsctx = jsrt.newJSContext()
   var warnings = newSeq[string]()
-  let config = Config(jsctx: jsctx)
-  if (let res = ctx.initConfig(config, warnings); res.isNone):
+  let config = Config(arraySeen: newTable[string, int]())
+  if (let res = ctx.initConfig(config, warnings, jsctx); res.isNone):
     stderr.writeLine(res.error)
     quit(1)
   var history = true
diff --git a/todo b/todo
index beaf0384..9cfcb22f 100644
--- a/todo
+++ b/todo
@@ -13,13 +13,8 @@ display:
 - dark mode (basically max Y)
 config:
 - important: config editor
-- switch from table arrays to tables
 - better siteconf URL matching
 - $TERM-based display config
-- better path handling (e.g. inline files, so we could get rid of css
-  "include/inline" etc.)
-- add per-scheme env var configuration (e.g.
-  proto.gemini.known-hosts = '/some/path'; maybe also with inline JS?)
 - add RPC for CGI scripts e.g. toggle settings/issue downloads/etc
 	* also some way to set permissions for RPC calls
 mailcap:
@@ -41,7 +36,7 @@ buffer:
 - configurable/better url filtering in loader
 - when the log buffer crashes, print its contents to stderr
 	* easiest way seems to be to just dump its cache file
-- add buffer groups
+- add tabs
 - xhtml
 pager:
 - handle long lines