]> git.ipfire.org Git - thirdparty/bird.git/commitdiff
Doc: autoconvertor of our SGML to Markdown
authorMaria Matejka <mq@ucw.cz>
Sun, 19 Jan 2025 00:06:24 +0000 (01:06 +0100)
committerMaria Matejka <mq@ucw.cz>
Tue, 1 Apr 2025 10:09:11 +0000 (12:09 +0200)
Some minor changes were done in the original documentation to allow for
easier conversion, and also to make the documentation a little bit more
strictly valid.

Makefile.in
configure.ac
doc/Makefile
doc/bird.sgml
tools/linuxdoc.lua [new file with mode: 0644]
tools/logging.lua [new file with mode: 0644]

index 839efe2438fb21fbd89d230a64bc10ec7e53c264..29e627e1f439c70f81edf160c4bf640b3641dc51 100644 (file)
@@ -22,6 +22,7 @@ RANLIB=@RANLIB@
 INSTALL=@INSTALL@
 INSTALL_PROGRAM=@INSTALL_PROGRAM@
 INSTALL_DATA=@INSTALL_DATA@
+PANDOC=@PANDOC@
 
 client=$(addprefix $(exedir)/,@CLIENT@)
 daemon=$(exedir)/bird
index 3eeb446d3c37094ce33c0778f02ae71701d19e78..cf6a2d581faed39829d1cfe0d2939e08db6c5451 100644 (file)
@@ -83,6 +83,7 @@ AC_ARG_WITH([iproutedir],
 AC_ARG_VAR([FLEX], [location of the Flex program])
 AC_ARG_VAR([BISON], [location of the Bison program])
 AC_ARG_VAR([M4], [location of the M4 program])
+AC_ARG_VAR([PANDOC], [location of the Pandoc program])
 
 if test "$enable_debug_expensive" = yes; then
   enable_debug=yes
@@ -188,6 +189,7 @@ AC_PROG_RANLIB
 AC_CHECK_PROG([FLEX], [flex], [flex])
 AC_CHECK_PROG([BISON], [bison], [bison])
 AC_CHECK_PROGS([M4], [gm4 m4])
+AC_CHECK_PROG([PANDOC], [pandoc], [pandoc])
 
 test -z "$FLEX"         && AC_MSG_ERROR([Flex is missing.])
 test -z "$BISON" && AC_MSG_ERROR([Bison is missing.])
index 0d1deb8ebc84c7eb0356abe676df53afd279607b..c176488774706aaf1070cbf9f488b20026fe7056 100644 (file)
@@ -25,6 +25,20 @@ $(o)%.sgml: $(s)%.sgml $(objdir)/.dir-stamp
 $(o)%.html: $(o)%.sgml
        cd $(dir $@) && $(toolsdir)/linuxdoc -B html $(notdir $<)
 
+ifeq ($(PANDOC),)
+$(o)%.md: $(s)%.sgml
+       @echo "ERROR: No pandoc available, install pandoc to build documentation"
+       @false
+else
+LINUXDOC_PANDOC_PARSER := $(srcdir)/tools/linuxdoc.lua
+$(o)%.md: $(s)%.sgml $(LINUXDOC_PANDOC_PARSER) $(objdir)/.dir-stamp
+       $(PANDOC) -f $(LINUXDOC_PANDOC_PARSER) -s -t markdown -o $@ $<
+
+$(o)%-singlepage.html: $(o)%.md
+       $(PANDOC) -f markdown -t html5 -s -o $@ $<
+
+endif
+
 $(o)%.tex: $(o)%.sgml
        cd $(dir $@) && $(toolsdir)/linuxdoc -B latex --output=tex $(notdir $<)
 
index 6585672f7f1128b4ba7129eb110d73cda9126422..f96f608d031a443b1672b7eeeb7f452d3abcce1f 100644 (file)
@@ -785,8 +785,8 @@ to set options.
        server setups, running GC on hundreds of full BGP routing tables can
        take significant amount of time, therefore they should use higher GC
        periods. Default: adaptive, based on number of routing tables in the
-       configuration. From 10 s (with <= 25 routing tables) up to 600 s (with
-       >= 1500 routing tables).
+       configuration. From 10 s (with &lt;= 25 routing tables) up to 600 s (with
+       &gt;= 1500 routing tables).
 </descrip>
 
 
@@ -2739,14 +2739,14 @@ protocol bfd [<name>] {
        passive mode, which means that the router does not send BFD packets
        until it has received one from the other side. Default: disabled.
 
-       <tag>authentication none</tag>
+       <tag><label id="bfd-authentication-none">authentication none</tag>
        No passwords are sent in BFD packets. This is the default value.
 
-       <tag>authentication simple</tag>
+       <tag><label id="bfd-authentication-simple">authentication simple</tag>
        Every packet carries 16 bytes of password. Received packets lacking this
        password are ignored. This authentication mechanism is very weak.
 
-       <tag>authentication [meticulous] keyed md5|sha1</tag>
+       <tag><label id="bfd-authentication-keyed">authentication [meticulous] keyed md5|sha1</tag>
        An authentication code is appended to each packet. The cryptographic
        algorithm is keyed MD5 or keyed SHA-1. Note that the algorithm is common
        for all keys (on one interface), in contrast to OSPF or RIP, where it
@@ -2758,7 +2758,7 @@ protocol bfd [<name>] {
        offers better resistance to replay attacks but may require more
        computation.
 
-       <tag>password "<m>text</m>"</tag>
+       <tag><label id="bfd-password">password "<m>text</m>"</tag>
        Specifies a password used for authentication. See <ref id="proto-pass"
        name="password"> common option for detailed description. Note that
        password option <cf/algorithm/ is not available in BFD protocol. The
@@ -2822,45 +2822,45 @@ avoid routing loops.
 
 <p>
 <itemize>
-<item> <rfc id="4271"> - Border Gateway Protocol 4 (BGP)
-<item> <rfc id="1997"> - BGP Communities Attribute
-<item> <rfc id="2385"> - Protection of BGP Sessions via TCP MD5 Signature
-<item> <rfc id="2545"> - Use of BGP Multiprotocol Extensions for IPv6
-<item> <rfc id="2918"> - Route Refresh Capability
-<item> <rfc id="3107"> - Carrying Label Information in BGP
-<item> <rfc id="4360"> - BGP Extended Communities Attribute
-<item> <rfc id="4364"> - BGP/MPLS IPv4 Virtual Private Networks
-<item> <rfc id="4456"> - BGP Route Reflection
-<item> <rfc id="4486"> - Subcodes for BGP Cease Notification Message
-<item> <rfc id="4659"> - BGP/MPLS IPv6 Virtual Private Networks
-<item> <rfc id="4724"> - Graceful Restart Mechanism for BGP
-<item> <rfc id="4760"> - Multiprotocol extensions for BGP
-<item> <rfc id="4798"> - Connecting IPv6 Islands over IPv4 MPLS
-<item> <rfc id="5065"> - AS confederations for BGP
-<item> <rfc id="5082"> - Generalized TTL Security Mechanism
-<item> <rfc id="5492"> - Capabilities Advertisement with BGP
-<item> <rfc id="8955"> - Dissemination of Flow Specification Rules for IPv4
-<item> <rfc id="8956"> - Dissemination of Flow Specification Rules for IPv6
-<item> <rfc id="5668"> - 4-Octet AS Specific BGP Extended Community
-<item> <rfc id="5925"> - TCP Authentication Option
-<item> <rfc id="6286"> - AS-Wide Unique BGP Identifier
-<item> <rfc id="6608"> - Subcodes for BGP Finite State Machine Error
-<item> <rfc id="6793"> - BGP Support for 4-Octet AS Numbers
-<item> <rfc id="7311"> - Accumulated IGP Metric Attribute for BGP
-<item> <rfc id="7313"> - Enhanced Route Refresh Capability for BGP
-<item> <rfc id="7606"> - Revised Error Handling for BGP UPDATE Messages
-<item> <rfc id="7911"> - Advertisement of Multiple Paths in BGP
-<item> <rfc id="7947"> - Internet Exchange BGP Route Server
-<item> <rfc id="8092"> - BGP Large Communities Attribute
-<item> <rfc id="8203"> - BGP Administrative Shutdown Communication
-<item> <rfc id="8212"> - Default EBGP Route Propagation Behavior without Policies
-<item> <rfc id="8654"> - Extended Message Support for BGP
-<item> <rfc id="8950"> - Advertising IPv4 NLRI with an IPv6 Next Hop
-<item> <rfc id="9072"> - Extended Optional Parameters Length for BGP OPEN Message
-<item> <rfc id="9117"> - Revised Validation Procedure for BGP Flow Specifications
-<item> <rfc id="9234"> - Route Leak Prevention and Detection Using Roles
-<item> <rfc id="9494"> - Long-Lived Graceful Restart for BGP
-<item> <rfc id="9687"> - Send Hold Timer
+<item> <rfc id="4271"> &ndash; Border Gateway Protocol 4 (BGP)
+<item> <rfc id="1997"> &ndash; BGP Communities Attribute
+<item> <rfc id="2385"> &ndash; Protection of BGP Sessions via TCP MD5 Signature
+<item> <rfc id="2545"> &ndash; Use of BGP Multiprotocol Extensions for IPv6
+<item> <rfc id="2918"> &ndash; Route Refresh Capability
+<item> <rfc id="3107"> &ndash; Carrying Label Information in BGP
+<item> <rfc id="4360"> &ndash; BGP Extended Communities Attribute
+<item> <rfc id="4364"> &ndash; BGP/MPLS IPv4 Virtual Private Networks
+<item> <rfc id="4456"> &ndash; BGP Route Reflection
+<item> <rfc id="4486"> &ndash; Subcodes for BGP Cease Notification Message
+<item> <rfc id="4659"> &ndash; BGP/MPLS IPv6 Virtual Private Networks
+<item> <rfc id="4724"> &ndash; Graceful Restart Mechanism for BGP
+<item> <rfc id="4760"> &ndash; Multiprotocol extensions for BGP
+<item> <rfc id="4798"> &ndash; Connecting IPv6 Islands over IPv4 MPLS
+<item> <rfc id="5065"> &ndash; AS confederations for BGP
+<item> <rfc id="5082"> &ndash; Generalized TTL Security Mechanism
+<item> <rfc id="5492"> &ndash; Capabilities Advertisement with BGP
+<item> <rfc id="8955"> &ndash; Dissemination of Flow Specification Rules for IPv4
+<item> <rfc id="8956"> &ndash; Dissemination of Flow Specification Rules for IPv6
+<item> <rfc id="5668"> &ndash; 4-Octet AS Specific BGP Extended Community
+<item> <rfc id="5925"> &ndash; TCP Authentication Option
+<item> <rfc id="6286"> &ndash; AS-Wide Unique BGP Identifier
+<item> <rfc id="6608"> &ndash; Subcodes for BGP Finite State Machine Error
+<item> <rfc id="6793"> &ndash; BGP Support for 4-Octet AS Numbers
+<item> <rfc id="7311"> &ndash; Accumulated IGP Metric Attribute for BGP
+<item> <rfc id="7313"> &ndash; Enhanced Route Refresh Capability for BGP
+<item> <rfc id="7606"> &ndash; Revised Error Handling for BGP UPDATE Messages
+<item> <rfc id="7911"> &ndash; Advertisement of Multiple Paths in BGP
+<item> <rfc id="7947"> &ndash; Internet Exchange BGP Route Server
+<item> <rfc id="8092"> &ndash; BGP Large Communities Attribute
+<item> <rfc id="8203"> &ndash; BGP Administrative Shutdown Communication
+<item> <rfc id="8212"> &ndash; Default EBGP Route Propagation Behavior without Policies
+<item> <rfc id="8654"> &ndash; Extended Message Support for BGP
+<item> <rfc id="8950"> &ndash; Advertising IPv4 NLRI with an IPv6 Next Hop
+<item> <rfc id="9072"> &ndash; Extended Optional Parameters Length for BGP OPEN Message
+<item> <rfc id="9117"> &ndash; Revised Validation Procedure for BGP Flow Specifications
+<item> <rfc id="9234"> &ndash; Route Leak Prevention and Detection Using Roles
+<item> <rfc id="9494"> &ndash; Long-Lived Graceful Restart for BGP
+<item> <rfc id="9687"> &ndash; Send Hold Timer
 </itemize>
 
 <sect1>Route selection rules
@@ -4081,7 +4081,7 @@ nothing about it.
 as one known by BIRD, therefore use of this statement carries a risk of
 incompatibility with future BIRD versions.
 
-<tt><label id="bgp-attribute-custom">attribute bgp <m/number/ bytestring <m/name/;</tt>
+<p><cf><label id="bgp-attribute-custom">attribute bgp <m/number/ bytestring <m/name/;</cf>
 
 <sect1>Example
 <label id="bgp-exam">
@@ -6121,6 +6121,7 @@ protocol rip {
 <label id="rpki">
 
 <sect1>Introduction
+<label id="rpki-introduction">
 
 <p>The Resource Public Key Infrastructure (RPKI) is mechanism for origin
 validation of BGP routes (<rfc id="6480">). BIRD supports only so-called
@@ -6144,8 +6145,8 @@ of ASPAs. You can then validate AS paths using function <cf/aspa_check()/
 in (import) filters.
 
 <sect1>Supported transports
-<p>
-<itemize>
+<label id="rpki-transport">
+<p><itemize>
         <item>Unprotected transport over TCP uses a port 323. The cache server
         and BIRD router should be on the same trusted and controlled network
         for security reasons.
@@ -6153,7 +6154,8 @@ in (import) filters.
         22.
 </itemize>
 
-<sect1>Configuration
+<sect1>Configuration overview
+<label id="rpki-configuration">
 
 <p>We currently support just one cache server per protocol. However you can
 define more RPKI protocols generally.
@@ -6190,21 +6192,22 @@ prefixes only. If you want to fetch both IPv4 and even IPv6 ROAs you have to
 specify both channels.
 
 <sect2>RPKI protocol options
+<label id="rpki-common-options">
 <p>
 <descrip>
-        <tag>remote <m/ip/ | "<m/hostname/" [port <m/number/]</tag> Specifies
+        <tag><label id="rpki-remote">remote <m/ip/ | "<m/hostname/" [port <m/number/]</tag> Specifies
         a destination address of the cache server.  Can be specified by an IP
         address or by full domain name string.  Only one cache can be specified
         per protocol. This option is required.
 
-        <tag>port <m/number/</tag> Specifies the port number. The default port
+        <tag><label id="rpki-port">port <m/number/</tag> Specifies the port number. The default port
         number is 323 for transport without any encryption and 22 for transport
         with SSH encryption.
 
-        <tag>local address <m/ip/</tag>
+        <tag><label id="rpki-local-address">local address <m/ip/</tag>
         Define local address we should use as a source address for the RTR session.
 
-        <tag>refresh [keep] <m/number/</tag> Time period in seconds. Tells how
+        <tag><label id="rpki-refresh">refresh [keep] <m/number/</tag> Time period in seconds. Tells how
         long to wait before next attempting to poll the cache using a Serial
         Query or a Reset Query packet. Must be lower than 86400 seconds (one
         day). Too low value can caused a false positive detection of
@@ -6212,73 +6215,77 @@ specify both channels.
         this value by a cache server.
         Default: 3600 seconds
 
-        <tag>retry [keep] <m/number/</tag> Time period in seconds between a failed
+        <tag><label id="rpki-retry">retry [keep] <m/number/</tag> Time period in seconds between a failed
         Serial/Reset Query and a next attempt.  Maximum allowed value is 7200
         seconds (two hours). Too low value can caused a false positive
         detection of network connection problems.  A keyword <cf/keep/
         suppresses updating this value by a cache server.
         Default: 600 seconds
 
-        <tag>expire [keep] <m/number/</tag> Time period in seconds. Received
+        <tag><label id="rpki-expire">expire [keep] <m/number/</tag> Time period in seconds. Received
         records are deleted if the client was unable to successfully refresh
         data for this time period.  Must be in range from 600 seconds (ten
         minutes) to 172800 seconds (two days).  A keyword <cf/keep/
         suppresses updating this value by a cache server.
         Default: 7200 seconds
 
-       <tag>ignore max length <m/switch/</tag>
+       <tag><label id="rpki-ignore-max-length">ignore max length <m/switch/</tag>
        Ignore received max length in ROA records and use max value (32 or 128)
        instead. This may be useful for implementing loose RPKI check for
        blackholes. Default: disabled.
 
-       <tag>min version <m/number/</tag>
+       <tag><label id="rpki-min-version">min version <m/number/</tag>
        Minimal allowed version of the RTR protocol. BIRD will refuse to
        downgrade a connection below this version and drop the session instead.
        Default: 0
 
-       <tag>max version <m/number/</tag>
+       <tag><label id="rpki-max-version">max version <m/number/</tag>
        Maximal allowed version of the RTR protocol. BIRD will start with this
         version. Use this option if sending version 2 to your cache causes
         problems. Default: 2
 
-        <tag>transport tcp { <m/TCP transport options.../ }</tag> Transport over
+        <tag><label id="rpki-transport-tcp">transport tcp { <m/TCP transport options.../ }</tag> Transport over
         TCP, it's the default transport. Cannot be combined with a SSH transport.
         Default: TCP, no authentication.
 
-        <tag>transport ssh { <m/SSH transport options.../ }</tag> It enables a
+        <tag><label id="rpki-transport-ssh">transport ssh { <m/SSH transport options.../ }</tag> It enables a
         SSHv2 transport encryption. Cannot be combined with a TCP transport.
         Default: off
 </descrip>
 
 <sect3>TCP transport options
-<p>
-<descrip>
-       <tag>authentication none|md5</tag>
+<label id="rpki-tcp-transport-options">
+<p><descrip>
+       <tag><label id="rpki-tcp-authentication">authentication none|md5</tag>
        Select authentication method to be used. <cf/none/ means no
        authentication, <cf/md5/ is TCP-MD5 authentication (<rfc id="2385">).
        Default: no authentication.
 
-       <tag>password "<m>text</m>"</tag>
+       <tag><label id="rpki-tcp-password">password "<m>text</m>"</tag>
        Use this password for TCP-MD5 authentication of the RPKI-To-Router session.
 </descrip>
 
 <sect3>SSH transport options
-<p>
-<descrip>
-       <tag>bird private key "<m>/path/to/id_rsa</m>"</tag>
+<label id="ssh-tcp-transport-options">
+<p><descrip>
+       <tag><label id="rpki-ssh-private-key">bird private key "<m>/path/to/id_rsa</m>"</tag>
        A path to the BIRD's private SSH key for authentication.
        It can be a <cf><m>id_rsa</m></cf> file.
 
-       <tag>remote public key "<m>/path/to/known_host</m>"</tag>
+       <tag><label id="rpki-ssh-remote-public-key">remote public key "<m>/path/to/known_host</m>"</tag>
        A path to the cache's public SSH key for verification identity
        of the cache server. It could be a path to <cf><m>known_host</m></cf> file.
 
-       <tag>user "<m/name/"</tag>
-       A SSH user name for authentication. This option is required.
+       <tag><label id="rpki-ssh-user">user "<m/name/"</tag>
+       A SSH user name for authentication. This option is required.
 </descrip>
 
 <sect1>Examples
+<label id="rpki-examples">
+
 <sect2>BGP origin validation
+<label id="rpki-example-bgp-origin-validation">
+
 <p>Policy: Don't import <cf/ROA_INVALID/ routes.
 <code>
 roa4 table r4;
@@ -6319,6 +6326,8 @@ protocol bgp {
 </code>
 
 <sect2>SSHv2 transport encryption
+<label id="rpki-example-sshv2-transport-encryption">
+
 <p>
 <code>
 roa4 table r4;
@@ -6388,12 +6397,13 @@ each labeled route.
 <p>Route definitions (each may also contain a block of per-route options):
 
 <sect1>Regular routes; MPLS switching rules
+<label id="static-mpls-rules">
 
 <p>There exist several types of routes; keep in mind that <m/prefix/ syntax is
 <ref id="type-prefix" name="dependent on network type">.
 
 <descrip>
-       <tag>route <m/prefix/ [mpls <m/number/] via <m/ip/|<m/"interface"/ [<m/per-nexthop options/] [via ...]</tag>
+       <tag><label id="static-route-regular">route <m/prefix/ [mpls <m/number/] via <m/ip/|<m/"interface"/ [<m/per-nexthop options/] [via ...]</tag>
        Regular routes may bear one or more <ref id="route-next-hop" name="next
        hops">. Every next hop is preceded by <cf/via/ and configured as shown.
 
@@ -6401,12 +6411,12 @@ each labeled route.
        after <m/prefix/ specifies a static label for the labeled route, instead
        of using dynamically allocated label.
 
-       <tag>route <m/prefix/ [mpls <m/number/] recursive <m/ip/ [mpls <m/number/[/<m/number/[/<m/number/[...]]]]</tag>
+       <tag><label id="static-route-recursive">route <m/prefix/ [mpls <m/number/] recursive <m/ip/ [mpls <m/number/[/<m/number/[/<m/number/[...]]]]</tag>
        Recursive nexthop resolves the given IP in the configured IGP table and
        uses that route's next hop. The MPLS stacks are concatenated; on top is
        the IGP's nexthop stack and on bottom is this route's stack.
 
-       <tag>route <m/prefix/ [mpls <m/number/] blackhole|unreachable|prohibit</tag>
+       <tag><label id="static-route-special">route <m/prefix/ [mpls <m/number/] blackhole|unreachable|prohibit</tag>
        Special routes specifying to silently drop the packet, return it as
        unreachable or return it as administratively prohibited. First two
        targets are also known as <cf/drop/ and <cf/reject/.
@@ -6418,6 +6428,7 @@ uninstalls the route from the table it is connected to and adds it again as soon
 as the destination becomes adjacent again.
 
 <sect2>Per-nexthop options
+<label id="static-per-nexthop-options">
 
 <p>There are several options that in a case of multipath route are per-nexthop
 (i.e., they can be used multiple times for a route, one time for each nexthop).
@@ -6464,39 +6475,42 @@ options (<cf/bfd/ and <cf/weight 1/), the second nexthop has just <cf/weight 2/.
 </descrip>
 
 <sect1>Route Origin Authorization
+<label id="static-route-origin-authorization">
 
 <p>The ROA config is just <cf>route <m/prefix/ max <m/int/ as <m/int/</cf> with no nexthop.
 
 <sect1>Autonomous System Provider Authorization
+<label id="static-autonomous-system-provider-authorization">
 
 <p>The ASPA config is <cf>route aspa <m/int/ providers <m/int/ [, <m/int/ ...]</cf> with no nexthop.
   The first ASN is client and the following are a list of providers.
   For a transit, you can also write <cf>route aspa <m/int/ transit</cf> to get
   the no-provider ASPA.
 
-<sect1>Flowspec
-<label id="flowspec-network-type">
+<sect1>Flowspec Network Type
+<label id="static-flowspec-network-type">
 
 <p>The flow specification are rules for routers and firewalls for filtering
 purpose. It is described by <rfc id="8955"> and <rfc id="8956">. There are 3 types of arguments:
 <m/inet4/ or <m/inet6/ prefixes, numeric matching expressions and bitmask
 matching expressions.
 
-Numeric matching is a matching sequence of numbers and ranges separeted by a
+<p>Numeric matching is a matching sequence of numbers and ranges separeted by a
 commas (<cf/,/) (e.g. <cf/10,20,30/). Ranges can be written using double dots
 <cf/../ notation (e.g. <cf/80..90,120..124/). An alternative notation are
 sequence of one or more pairs of relational operators and values separated by
 logical operators <cf/&&/ or <cf/||/. Allowed relational operators are <cf/=/,
 <cf/!=/, <cf/</, <cf/<=/, <cf/>/, <cf/>=/, <cf/true/ and <cf/false/.
 
-Bitmask matching is written using <m/value/<cf>/</cf><m/mask/ or
+<p>Bitmask matching is written using <m/value/<cf>/</cf><m/mask/ or
 <cf/!/<m/value/<cf>/</cf><m/mask/ pairs. It means that <cf/(/<m/data/ <cf/&/
 <m/mask/<cf/)/ is or is not equal to <m/value/. It is also possible to use
-multiple value/mask pairs connected by logical operators <cf/&&/ or <cf/||/.
+multiple value/mask pairs connected by logical operators <cf/&amp;&amp;/ or <cf/||/.
 Note that for negated matches, value must be either zero or equal to bitmask
-(e.g. !0x0/0xf or !0xf/0xf, but not !0x3/0xf).
+(e.g. <cf>!0x0/0xf</cf> or <cf>!0xf/0xf</cf>, but not <cf>!0x3/0xf</cf>).
 
 <sect2>IPv4 Flowspec
+<label id="static-flowspec-ipv4">
 
 <p><descrip>
        <tag><label id="flow-dst">dst <m/inet4/</tag>
@@ -6541,7 +6555,7 @@ Note that for negated matches, value must be either zero or equal to bitmask
        <tag><label id="flow-fragment">fragment <m/fragmentation-type/</tag>
        Set a matching type of packet fragmentation. Allowed fragmentation
        types are <cf/dont_fragment/, <cf/is_fragment/, <cf/first_fragment/,
-       <cf/last_fragment/ (e.g. <cf>fragment is_fragment &&
+       <cf/last_fragment/ (e.g. <cf>fragment is_fragment &amp;&amp;
        !dont_fragment</cf>).
 </descrip>
 
@@ -6561,6 +6575,7 @@ protocol static {
 </code>
 
 <sect2>Differences for IPv6 Flowspec
+<label id="static-flowspec-ipv6">
 
 <p>Flowspec IPv6 are same as Flowspec IPv4 with a few exceptions.
 <itemize>
@@ -6609,6 +6624,7 @@ protocol static {
 </code>
 
 <sect1>Per-route options
+<label id="static-per-route-options">
 <p>
 <descrip>
        <tag><label id="static-route-filter"><m/filter expression/</tag>
diff --git a/tools/linuxdoc.lua b/tools/linuxdoc.lua
new file mode 100644 (file)
index 0000000..cf4326f
--- /dev/null
@@ -0,0 +1,438 @@
+-- Based on a sample custom reader that just parses text into blankline-separated
+-- paragraphs with space-separated words.
+--
+-- Source: https://pandoc.org/custom-readers.html
+
+-- Debug logs
+local logging = require 'tools/logging'
+
+-- For better performance we put these functions in local variables:
+local P, S, R, Cf, Cc, Ct, V, Cs, Cg, Cb, B, C, Cmt =
+  lpeg.P, lpeg.S, lpeg.R, lpeg.Cf, lpeg.Cc, lpeg.Ct, lpeg.V,
+  lpeg.Cs, lpeg.Cg, lpeg.Cb, lpeg.B, lpeg.C, lpeg.Cmt
+
+local whitespacechar = S(" \t\r\n")
+local wordchar = (1 - whitespacechar)
+local spacechar = S(" \t")
+local newline = P"\r"^-1 * P"\n"
+
+local blankchar = S(" \t\r\n")
+local blankmore = blankchar^0
+
+local entitytab = {
+  lt = "<";
+  gt = ">";
+  ndash = "–";
+  tilde = "~";
+  amp = "&";
+  verbar = "|";
+}
+local entity = P"&" * C(P(1 - S"&;")^1) * P";" / function (t)
+  local e = entitytab[t]
+  if e == nil then return "!!ENTITY-" .. t .. "-ENTITY!!" else return e end
+end
+
+local inelement = blankmore * Ct((entity + C(1 - P"<"))^0) / function (t)
+  if #t == 0 then return "" end
+  while t[#t]:match("%s") do t[#t] = nil end -- strip trailing whitespace
+  return table.concat(t, "")
+end
+
+local ininline = Ct((entity + C(1 - P"/"))^0) / function (t)
+  if #t == 0 then return "" end
+  return table.concat(t, "")
+end
+
+function mergetables(t)
+--  logging.temp("merging", #t)
+  local n = {}
+  local v, q
+  for _, v in ipairs(t) do
+    if pandoc.utils.type(v) == "table" then
+--      logging.temp("is table", #v)
+      for _, q in ipairs(v) do
+       table.insert(n, q)
+      end
+    elseif pandoc.utils.type(v) == "Inline" and v.text == "" then
+      -- ignore this
+    else
+--      logging.temp("direct", v, pandoc.utils.type(v), "x", v.text, "x")
+      table.insert(n, v)
+    end
+  end
+--  logging.temp("returning", #n)
+  return n
+end
+
+-- Grammar
+G = P{ "Pandoc",
+  Pandoc = P"<!doctype birddoc system>" * V"BIRDDoc" / function (t)
+    doc = {}
+    meta = {}
+    for _, v in ipairs(t) do
+      -- Split out meta blocks
+      if pandoc.utils.type(v) == "Meta" then
+       for mk,mv in pairs(v) do
+         meta[mk] = mv
+       end
+      else
+       table.insert(doc, v)
+      end
+    end
+--    logging.temp('pandoc', t[1], t[2][2], t[3])
+    return pandoc.Pandoc(doc, meta)
+  end;
+
+  BIRDDoc = Ct((blankchar + V"Comment" + V"Book")^1) / mergetables;
+
+  CommentInside = (1 - P"-->") / pandoc.Str;
+  Comment = P"<!--" * Ct(V"CommentInside"^1) * P"-->" / function (t)
+--    logging.temp("COMMENT", t)
+    return pandoc.Str("")
+  end;
+
+  BookInside = V"Comment" + V"BookIgnored" + V"Title" + V"Author" + V"Abstract" + V"Chapter" + blankchar + V"ParseFail";
+  BookIgnored = P"<toc>";
+  Book = P"<book>" * Ct(V"BookInside"^1) * P"</book>" / mergetables;
+
+  Title = P"<title>" * inelement / function (t)
+    return {
+      pandoc.Meta({ title = t });
+--      pandoc.Header(1, t);
+    } end;
+
+  Author = P"<author>" * blankmore * Ct((V"AuthorOne")^1) * blankmore * P"</author>" / function (t)
+    return pandoc.Meta({ author = t })
+  end;
+  AuthorOne = inelement * P"<it/&lt;" * C((1 - S"&")^1) * P("&gt;/") * P(",")^0 / function (n, e)
+--    return { name = n; email = e; }
+    return n .. " <" .. e .. ">"
+  end;
+
+  Abstract = P"<abstract>" * inelement * P"</abstract>" / function (t)
+    return {
+      pandoc.Meta({ abstract = t });
+--      pandoc.Emph(t);
+    }
+  end;
+
+  Chapter = P"<chapt>" * inelement * V"Label" * Ct(V"ChapterInside"^1) / function (name, label, inside)
+--    logging.temp("chapt", name, label)
+    return mergetables({
+      pandoc.Header(1, name, { id = label });
+      mergetables(inside);
+    })
+  end;
+  ChapterInside = V"Sect" + blankchar;
+
+  Sect = P"<sect>" * inelement * V"Label" * Ct(V"SectInside"^0) / function (name, label, inside)
+--    logging.temp("sect", name, label, #inside)
+    return mergetables({
+      pandoc.Header(2, name, { id = label });
+      mergetables(inside);
+    })
+  end;
+  SectInside =
+    V"Sect1" +
+    V"Sect1Inside";
+
+  Sect1 = P"<sect1>" * inelement * V"Label" * Ct((V"Sect1Inside" - P"<sect1>")^0) / function (name, label, inside)
+    return mergetables({
+      pandoc.Header(3, name, { id = label });
+      mergetables(inside);
+    })
+  end;
+  Sect1Inside =
+    V"Sect2" +
+    V"Sect2Inside";
+
+  Sect2 = P"<sect2>" * inelement * V"Label" * Ct((V"Sect2Inside" - P"<sect1>" - P"<sect2>")^0) / function (name, label, inside)
+    return mergetables({
+      pandoc.Header(4, name, { id = label });
+      mergetables(inside);
+    })
+  end;
+  Sect2Inside =
+    V"Sect3" +
+    V"Sect3Inside";
+
+  Sect3 = P"<sect3>" * inelement * V"Label" * Ct((V"Sect3Inside" - P"<sect1>" - P"<sect2>" - P"<sect3>")^0) / function (name, label, inside)
+    return mergetables({
+      pandoc.Header(5, name, { id = label });
+      mergetables(inside);
+    })
+  end;
+  Sect3Inside =
+    V"Para" +
+    V"ItemList" +
+    V"DescripList" +
+    V"CodeBlock" +
+    V"TableBlock" +
+    blankchar + V"ParseFail";
+
+  Para = P"<p>" * Ct(V"InPara") * P"</p>"^-1 / function (t)
+--    logging.temp("para", #t)
+    return pandoc.Para(mergetables(t))
+  end;
+
+  InParaItems =
+      V"Emph" +
+      V"It" +
+      V"HTMLURL" +
+      V"InlineCodeLong" +
+      V"InlineCodeShort" +
+      V"InlineCodeIt" +
+      V"InlineCodeItLong" +
+      V"InlineConfLong" +
+      V"InlineConfShort" +
+      V"FilePathLong" +
+      V"FilePathShort" +
+      V"RFCRef" +
+      V"InternalRef" +
+      V"Comment" +
+      (V"Label" / function (e) return pandoc.Span({}, { id = e }) end);
+
+  InPara = blankmore * Ct((
+      V"InParaItems" +
+      entity + C(1 - P"<")
+      )^0) * blankmore / function (t)
+    buf = {}
+    out = {}
+    t = mergetables(t)
+    if #t > 0 then
+      while pandoc.utils.type(t[#t]) == "string"
+       and t[#t]:match("%s") do
+       t[#t] = nil
+      end
+    end
+    for _,v in ipairs(t) do
+      if pandoc.utils.type(v) == "string" then
+       table.insert(buf, v)
+      else
+       if #buf > 0 then
+         table.insert(out, pandoc.Str(table.concat(buf, "")))
+         buf = {}
+       end
+       table.insert(out, v)
+      end
+    end
+    if #buf > 0 then
+      table.insert(out, pandoc.Str(table.concat(buf, "")))
+    end
+    return out
+--      logging.temp("inpara", pandoc.utils.type(v), v) end
+  end;
+
+  ParaBreak = C(P"\n\n" + P"<p>");
+
+  InDescrip = blankmore * Ct((
+      V"ParaBreak" +
+      V"InParaItems" +
+      V"CodeBlock" +
+      entity + C(1 - P"<")
+      )^0) * blankmore / function (t)
+    local inlines = {}
+    local blocks = {}
+    local t = mergetables(t)
+--    logging.temp("indescrip in", t)
+    if #t > 0 then
+      while pandoc.utils.type(t[#t]) == "string"
+       and t[#t]:match("%s") do
+       t[#t] = nil
+      end
+    end
+    for _,v in ipairs(t) do
+      if pandoc.utils.type(v) == "string" then
+       if v == "\n\n" or v == "<p>" then
+         if #inlines > 0 then
+           table.insert(blocks, pandoc.Para(inlines))
+           inlines = {}
+         end
+       elseif #inlines > 0 or not v:match("^%s+$") then
+--       logging.temp("inserting", v, "inlines", #inlines)
+         table.insert(inlines, pandoc.Str(v))
+       end
+      elseif pandoc.utils.type(v) == "Inline" then
+       table.insert(inlines, v)
+      elseif pandoc.utils.type(v) == "Block" then
+       if #inlines > 0 then
+         table.insert(blocks, pandoc.Para(inlines))
+         inlines = {}
+       end
+       table.insert(blocks, v)
+      else
+       error("unexpected pandoc type " .. pandoc.utils.type(v))
+      end
+    end
+    if #inlines > 0 then
+      table.insert(blocks, pandoc.Para(inlines))
+    end
+--    logging.temp("indescrip out", blocks)
+    return blocks
+  end;
+
+  Emph = P"<em/" * ininline * P"/" / pandoc.Strong;
+  It = P"<it/" * ininline * P"/" / pandoc.Emph;
+  InlineCodeIt = (P"<m/" + P"<M/") * ininline * P"/" / function (e)
+    return pandoc.Emph(e, { class = "code" })
+  end;
+  InlineCodeItLong = (P"<m>" + P"<M>") * inelement * (P"</m>" + P"</M>") / function (e)
+    return pandoc.Emph(e, { class = "code" })
+  end;
+
+  HTMLURL = P"<HTMLURL" * Ct((
+      P'URL="' * Cg((1 - S'"')^1, "url") * P'"'
+    + P'name="' * Cg((1 - S'"')^1, "text") * P'"'
+    + blankchar
+  )^1) * P">" / function (t)
+    return pandoc.Link(t.text, t.url)
+  end;
+
+  InternalRef = P"<ref" * Ct((
+      P'id="' * Cg((1 - S'"')^1, "url") * P'"'
+    + P'name="' * Cg((1 - S'"')^1, "text") * P'"'
+    + blankchar
+  )^1) * P">" / function (t)
+    return pandoc.Link(t.text, "#" .. t.url)
+  end;
+
+  RFCRef = P"<rfc" * Ct((
+      P'id="' * Cg((1 - S'"')^1, "url") * P'"'
+    + blankchar
+  )^1) * P">" / function (t)
+    -- TODO: create a custom markdown extension for this
+    return pandoc.Link("RFC " .. t.url, "https://datatracker.ietf.org/doc/rfc" .. t.url, nil, { class = "rfc" })
+  end;
+
+  InlineCodeLong = P'<tt>' * inelement * P'</tt>' / pandoc.Code;
+  InlineCodeShort = P'<tt/' * ininline * P'/' / pandoc.Code;
+  InlineConfLong = P'<cf>' * V"InPara" * P'</cf>' / function (t)
+--    logging.temp("inlineconflong", t)
+    buf = {}
+    out = {}
+    for _,v in ipairs(t) do
+      if pandoc.utils.type(v) == "Inline" and v.tag == "Str" then
+       table.insert(buf, v.text)
+      else
+--     logging.temp("got type", pandoc.utils.type(v))
+       if #buf > 0 then
+         table.insert(out, pandoc.Code(table.concat(buf, "")))
+         buf = {}
+       end
+       table.insert(out, v)
+      end
+    end
+    if #buf > 0 then
+      table.insert(out, pandoc.Code(table.concat(buf, "")))
+    end
+--    logging.temp("inlineconflong out", out)
+    return out
+  end;
+  InlineConfShort = P'<cf/' * ininline * P'/' / function (e)
+    return pandoc.Code(e, { class = "config" })
+  end;
+  FilePathLong = P'<file>' * inelement * P'</file>' / function (e)
+    return pandoc.Code(e, { class = "filepath" })
+  end;
+  FilePathShort = P'<file/' * ininline * P'/' / function (e)
+    return pandoc.Code(e, { class = "filepath" })
+  end;
+
+  Label = P'<label id="' * C((1 - P('"'))^0) * P'">';
+
+  ItemList = P"<itemize>" * Ct((V"ItemListItem" + blankchar)^1) * P"</itemize>" / pandoc.BulletList;
+  ItemListItem = P"<item>" * V"InPara";
+
+  DescripList = P"<descrip>" * Ct((V"DescripListItem" + blankchar)^1) * P"</descrip>" / pandoc.DefinitionList;
+  DescripListItem = P"<tag>" * V"Label" * V"InPara" * "</tag>" * (V"InDescrip" - P"<tag>" - P"</descrip>") / function (l,t,u)
+--    logging.temp("dli", t,u)
+    return { pandoc.Span(t, { class = "code", id = l }), { u }}
+  end;
+
+  CodeBlock = P'<code>' * C((1 - P'</code>')^0) * P'</code>' / pandoc.CodeBlock;
+
+  TableBlockIgnoreBf = P'<bf/' * ininline * '/';
+
+  -- There is only one table
+  TableBlock = P'<table loc="h">' * blankmore * P'<tabular ca="l|l|l|r|r">' * blankmore * Ct((
+    P'<hline>' +
+    V"TableBlockIgnoreBf" +
+    V"InParaItems" +
+    entity + C(1 - P'</tabular>')
+    )^0) * blankmore * P'</tabular>' * blankmore * P'</table>' / function(t)
+      -- in t, the whole string is split by chars
+      local row = {}
+      local tbody = {}
+      local thead = nil
+      local finishrow = function(row)
+       local cell = {}
+       local rowblock = {}
+       local finishcell = function(cell)
+--       logging.temp("cell unstripped", cell)
+         while pandoc.utils.type(cell[#cell]) == "string" and
+           cell[#cell]:match("^%s$") do
+           cell[#cell] = nil
+         end
+--       logging.temp("cell from", cell)
+         table.insert(rowblock, pandoc.Cell(pandoc.Para(cell)))
+       end
+
+       for _,w in ipairs(row) do
+         if w == "|" then
+           finishcell(cell)
+           cell = {}
+         elseif #cell == 0 and
+           (pandoc.utils.type(w) == "string") and
+           w:match("^%s$") then
+--         logging.temp("ignoring", w)
+         else
+           table.insert(cell, w)
+         end
+       end
+
+       finishcell(cell)
+
+--     logging.temp("row from", row, "to", rowblock)
+       if thead == nil then
+         thead = pandoc.Row(rowblock)
+       else
+         table.insert(tbody, pandoc.Row(rowblock))
+       end
+      end
+
+      for _,v in ipairs(t) do
+       if v == "@" then
+         finishrow(row)
+         row = {}
+       else
+         table.insert(row, v)
+       end
+      end
+
+      finishrow(row)
+--      logging.temp("table body", tbody)
+
+      return pandoc.Table(
+       {
+         long = "BGP channel variants";
+       },
+       {
+         { "AlignLeft", 0.60 },
+         { "AlignLeft", 0.60 },
+         { "AlignLeft", 0.60 },
+         { "AlignRight", 0.20 },
+         { "AlignRight", 0.20 },
+       },
+       pandoc.TableHead({thead}),
+       --{ body = tbody },
+       {{ body = tbody, attr = pandoc.Attr(), row_head_columns = 0, head = {} }},
+       pandoc.TableFoot()
+      )
+  end;
+
+  ParseFail = (1 - P"<sect>" - P"<chapt>" - P"</book>") / function (t) return pandoc.CodeBlock("PARSER FAILED " .. t) end;
+}
+
+function Reader(input)
+  return lpeg.match(G, tostring(input))
+end
diff --git a/tools/logging.lua b/tools/logging.lua
new file mode 100644 (file)
index 0000000..a911849
--- /dev/null
@@ -0,0 +1,271 @@
+--[[
+    logging.lua: pandoc-aware logging functions (can also be used standalone)
+    Copyright:   (c) 2022 William Lupton
+    License:     MIT - see LICENSE file for details
+    Usage:       See README.md for details
+    Source:     https://github.com/pandoc-ext/logging/blob/main/logging.lua
+]]
+
+-- if running standalone, create a 'pandoc' global
+if not pandoc then
+    _G.pandoc = {utils = {}}
+end
+
+-- if there's no pandoc.utils, create a local one
+if not pcall(require, 'pandoc.utils') then
+    pandoc.utils = {}
+end
+
+-- if there's no pandoc.utils.type, create a local one
+if not pandoc.utils.type then
+    pandoc.utils.type = function(value)
+        local typ = type(value)
+        if not ({table=1, userdata=1})[typ] then
+            -- unchanged
+        elseif value.__name then
+            typ = value.__name
+        elseif value.tag and value.t then
+            typ = value.tag
+            if typ:match('^Meta.') then
+                typ = typ:sub(5)
+            end
+            if typ == 'Map' then
+                typ = 'table'
+            end
+        end
+        return typ
+    end
+end
+
+-- namespace
+local logging = {}
+
+-- helper function to return a sensible typename
+logging.type = function(value)
+    -- this can return 'Inlines', 'Blocks', 'Inline', 'Block' etc., or
+    -- anything that built-in type() can return, namely 'nil', 'number',
+    -- 'string', 'boolean', 'table', 'function', 'thread', or 'userdata'
+    local typ = pandoc.utils.type(value)
+
+    -- it seems that it can also return strings like 'pandoc Row'; replace
+    -- spaces with periods
+    -- XXX I'm not sure that this is done consistently, e.g. I don't think
+    --     it's done for pandoc.Attr or pandoc.List?
+    typ = typ:gsub(' ', '.')
+
+    -- map Inline and Block to the tag name
+    -- XXX I guess it's intentional that it doesn't already do this?
+    return ({Inline=1, Block=1})[typ] and value.tag or typ
+end
+
+-- derived from https://www.lua.org/pil/19.3.html pairsByKeys()
+logging.spairs = function(list, comp)
+    local keys = {}
+    for key, _ in pairs(list) do
+        table.insert(keys, tostring(key))
+    end
+    table.sort(keys, comp)
+    local i = 0
+    local iter = function()
+        i = i + 1
+        return keys[i] and keys[i], list[keys[i]] or nil
+    end
+    return iter
+end
+
+-- helper function to dump a value with a prefix (recursive)
+-- XXX should detect repetition/recursion
+-- XXX would like maxlen logic to apply at all levels? but not trivial
+local function dump_(prefix, value, maxlen, level, add)
+    local buffer = {}
+    if prefix == nil then prefix = '' end
+    if level == nil then level = 0 end
+    if add == nil then add = function(item) table.insert(buffer, item) end end
+    local indent = maxlen and '' or ('  '):rep(level)
+
+    -- get typename, mapping to pandoc tag names where possible
+    local typename = logging.type(value)
+
+    -- don't explicitly indicate 'obvious' typenames
+    local typ = (({boolean=1, number=1, string=1, table=1, userdata=1})
+                 [typename] and '' or typename)
+
+    -- light userdata is just a pointer (can't iterate over it)
+    -- XXX is there a better way of checking for light userdata?
+    if type(value) == 'userdata' and not pcall(pairs(value)) then
+        value = tostring(value):gsub('userdata:%s*', '')
+
+    -- modify the value heuristically
+    elseif ({table=1, userdata=1})[type(value)] then
+        local valueCopy, numKeys, lastKey = {}, 0, nil
+        for key, val in pairs(value) do
+            -- pandoc >= 2.15 includes 'tag', nil values and functions
+            if key ~= 'tag' and val and type(val) ~= 'function' then
+                valueCopy[key] = val
+                numKeys = numKeys + 1
+                lastKey = key
+            end
+        end
+        if numKeys == 0 then
+            -- this allows empty tables to be formatted on a single line
+            -- XXX experimental: render Doc objects
+            value = typename == 'Doc' and '|' .. value:render() .. '|' or
+            typename == 'Space' and '' or '{}'
+        elseif numKeys == 1 and lastKey == 'text' then
+            -- this allows text-only types to be formatted on a single line
+            typ = typename
+            value = value[lastKey]
+            typename = 'string'
+        else
+            value = valueCopy
+            -- XXX experimental: indicate array sizes
+            if #value > 0 then
+                typ = typ .. '[' .. #value .. ']'
+            end
+        end
+    end
+
+    -- output the possibly-modified value
+    local presep = #prefix > 0 and ' ' or ''
+    local typsep = #typ > 0 and ' ' or ''
+    local valtyp = type(value)
+    if valtyp == 'nil' then
+        add('nil')
+    elseif ({boolean=1, number=1, string=1})[valtyp] then
+        typsep = #typ > 0 and valtyp == 'string' and #value > 0 and ' ' or ''
+        -- don't use the %q format specifier; doesn't work with multi-bytes
+        local quo = typename == 'string' and '"' or ''
+        add(string.format('%s%s%s%s%s%s%s%s', indent, prefix, presep, typ,
+                          typsep, quo, value, quo))
+    -- light userdata is just a pointer (can't iterate over it)
+    -- XXX is there a better way of checking for light userdata?
+    elseif valtyp == 'userdata' and not pcall(pairs(value)) then
+        add(string.format('%s%s%s%s %s', indent, prefix, presep, typ,
+                          tostring(value):gsub('userdata:%s*', '')))
+    elseif ({table=1, userdata=1})[valtyp] then
+        add(string.format('%s%s%s%s%s{', indent, prefix, presep, typ, typsep))
+        -- Attr and Attr.attributes have both numeric and string keys, so
+        -- ignore the numeric ones
+        -- XXX this is no longer the case for pandoc >= 2.15, so could remove
+        --     the special case?
+        local first = true
+        if prefix ~= 'attributes:' and typ ~= 'Attr' then
+            for i, val in ipairs(value) do
+                local pre = maxlen and not first and ', ' or ''
+                dump_(string.format('%s[%s]', pre, i), val, maxlen,
+                      level + 1, add)
+                first = false
+            end
+        end
+        -- report keys in alphabetical order to ensure repeatability
+        for key, val in logging.spairs(value) do
+            local pre = maxlen and not first and ', ' or ''
+            -- this check can avoid an infinite loop, e.g. with metatables
+            -- XXX should have more general and robust infinite loop avoidance
+            if key:match('^__') and type(val) ~= 'string' then
+                add(string.format('%s%s: %s', pre, key, tostring(val)))
+
+            -- pandoc >= 2.15 includes 'tag'
+            elseif not tonumber(key) and key ~= 'tag' then
+                dump_(string.format('%s%s:', pre, key), val, maxlen,
+                      level + 1, add)
+            end
+            first = false
+        end
+        add(string.format('%s}', indent))
+    end
+    return table.concat(buffer, maxlen and '' or '\n')
+end
+
+logging.dump = function(value, maxlen)
+    if maxlen == nil then maxlen = 70 end
+    local text = dump_(nil, value, maxlen)
+    if #text > maxlen then
+        text = dump_(nil, value, nil)
+    end
+    return text
+end
+
+logging.output = function(...)
+    local need_newline = false
+    for i, item in ipairs({...}) do
+        -- XXX space logic could be cleverer, e.g. no space after newline
+        local maybe_space = i > 1 and ' ' or ''
+        local text = ({table=1, userdata=1})[type(item)] and
+            logging.dump(item) or tostring(item)
+        io.stderr:write(maybe_space, text)
+        need_newline = text:sub(-1) ~= '\n'
+    end
+    if need_newline then
+        io.stderr:write('\n')
+    end
+end
+
+-- basic logging support (-1=errors, 0=warnings, 1=info, 2=debug, 3=debug2)
+-- XXX should support string levels?
+logging.loglevel = 0
+
+-- set log level and return the previous level
+logging.setloglevel = function(loglevel)
+    local oldlevel = logging.loglevel
+    logging.loglevel = loglevel
+    return oldlevel
+end
+
+-- verbosity default is WARNING; --quiet -> ERROR and --verbose -> INFO
+-- --trace sets TRACE or DEBUG (depending on --verbose)
+if type(PANDOC_STATE) == 'nil' then
+    -- use the default level
+elseif PANDOC_STATE.trace then
+    logging.loglevel = PANDOC_STATE.verbosity == 'INFO' and 3 or 2
+elseif PANDOC_STATE.verbosity == 'INFO' then
+    logging.loglevel = 1
+elseif PANDOC_STATE.verbosity == 'WARNING' then
+    logging.loglevel = 0
+elseif PANDOC_STATE.verbosity == 'ERROR' then
+    logging.loglevel = -1
+end
+
+logging.error = function(...)
+    if logging.loglevel >= -1 then
+        logging.output('(E)', ...)
+    end
+end
+
+logging.warning = function(...)
+    if logging.loglevel >= 0 then
+        logging.output('(W)', ...)
+    end
+end
+
+logging.info = function(...)
+    if logging.loglevel >= 1 then
+        logging.output('(I)', ...)
+    end
+end
+
+logging.debug = function(...)
+    if logging.loglevel >= 2 then
+        logging.output('(D)', ...)
+    end
+end
+
+logging.debug2 = function(...)
+    if logging.loglevel >= 3 then
+        logging.warning('debug2() is deprecated; use trace()')
+        logging.output('(D2)', ...)
+    end
+end
+
+logging.trace = function(...)
+    if logging.loglevel >= 3 then
+        logging.output('(T)', ...)
+    end
+end
+
+-- for temporary unconditional debug output
+logging.temp = function(...)
+    logging.output('(#)', ...)
+end
+
+return logging