subcommand: Implement roff and html output for com_man().
authorAndre Noll <maan@tuebingen.mpg.de>
Mon, 17 Apr 2017 14:07:31 +0000 (16:07 +0200)
committerAndre Noll <maan@tuebingen.mpg.de>
Thu, 30 Nov 2017 09:39:04 +0000 (10:39 +0100)
This implements the -m option of the internal man subcommand to specify
the output format for the manual page (text, roff, or html).  html is
generated by running the html postprocessor of groff. Another option,
-b, is introduced to let the user specify the browser for html output.

The commit introduces new public variable $gsu_package whose value is
shown at the bottom left of the man page.

The documentation is updated accordingly, and the example subcommand
of README.md has been enhaced to illustrate how subcommand options
should be formatted to look nice in both html and roff output.

README.md
common
subcommand

index 58d28a0c8d93e0df6608cad1f881cae8c2aa2080..0d2c9e236bf437c711cf1e26aa22f6777eeff179 100644 (file)
--- a/README.md
+++ b/README.md
@@ -127,28 +127,34 @@ Example:
                        echo 'hello world'
                }
 
-The subcommand documentation consists of three parts:
+The subcommand documentation consists of the following parts:
 
 - The summary. One line of text,
 - the usage/synopsis string,
-- free text section.
+- free text section 1,
+- options (if any),
+- free text section 2.
 
-The three parts should be separated by lines consisting of two `#` characters
-only. Example:
+The last three parts are optional. All parts should be separated by
+lines consisting of two `#` characters only. Example:
 
                com_world()
                ##
                ## Print the string "hello world" to stdout.
                ##
-               ## Usage: world
+               ## Usage: world [-v]
                ##
                ## Any arguments to this function are ignored.
                ##
+               ## -v: Enable verbose mode
+               ##
                ## Warning: This subcommand may cause the top most line of your terminal to
                ## disappear and may cause DATA LOSS in your scrollback buffer. Use with
                ## caution.
                {
-                       echo 'hello world'
+                       printf 'hello world'
+                       [[ "$1" == '-v' ]] && printf '!'
+                       printf '\n'
                }
 
 Replace `hello` with the above and try:
@@ -318,12 +324,17 @@ Example:
 
 ___HTML output___
 
-The output of the auto-generated man subcommand is a suitable input for the
-grutatxt plain text to html converter. Hence
+The auto-generated man subcommand produces plain text, html, or
+roff output.
+
+               ./hello man -m html > index.html
 
-               ./hello man | grutatxt > index.html
+is all it takes to produce an html page for your
+application. Similarly,
 
-is all it takes to produce an html page for your application.
+               ./hello man -m roff > hello.1
+
+creates a manual page.
 
 ___Interactive completion___
 
@@ -698,6 +709,10 @@ Defaults to `~/.${gsu_name}.rc`.
 - `$gsu_config_var_prefix`. Used by the config module to set up
 the variables defined in `$gsu_options`.
 
+- `$gsu_package`. Text shown at the bottom left of the man page,
+usually the name and version number of the software package. Defaults
+to `$gsu_name`.
+
 License
 -------
 gsu is licensed under the  GNU LESSER GENERAL PUBLIC LICENSE (LGPL), version 3.
@@ -712,4 +727,3 @@ References
 ----------
 - [bash](http://www.gnu.org/software/bash/bash.html)
 - [dialog](http://www.invisible-island.net/dialog/dialog.html)
-- [grutatxt](http://triptico.com/software/grutatxt.html)
diff --git a/common b/common
index ec7b8e2cfd816f1faadc6b2ffd8c45c2da37fc28..4b69b2351942178c22e7cc9141713bce5b62e0d6 100644 (file)
--- a/common
+++ b/common
@@ -14,11 +14,13 @@ E_GSU_BAD_BOOL                      bad value for boolian option
 E_GSU_BAD_OPTION_TYPE          invalid option type
 E_GSU_BAD_ARG_COUNT            invalid number of arguments
 E_GSU_EDITOR                   failed to execute editor
+E_GSU_INVAL                    invalid argument
 E_GSU_MKDIR                    failed to create directory
 E_GSU_GETOPTS                  getopts error
 E_GSU_DIALOG                   dialog error
 E_GSU_MKTEMP                   mktemp error
 E_GSU_MENU_TREE                        invalid menu tree
+E_GSU_XCMD                     external command failed
 ${gsu_errors:-}
 "
        local a b i=0
index 7318c9e880396e5118a117639050fac2ba063e95..a07f4145c69adb6fa319dbdaa9c97e3b0a2d5cde 100644 (file)
@@ -278,29 +278,310 @@ complete_prefs()
        gsu_complete_options "$com_prefs_options" "$@"
 }
 
+_gsu_man_options='m:b:'
+
+complete_man()
+{
+       gsu_complete_options "$_gsu_man_options" "$@"
+       ((ret > 0)) && return
+       gsu_cword_is_option_parameter "$_gsu_man_options" "$@"
+       [[ "$result" == 'm' ]] && printf 'roff\ntext\nhtml\n'
+}
+
 _gsu_man_txt='
 Print the manual.
 
-Usage: man
+Usage: man [-m <mode>] [-b <browser>]
+
+-m: Set output format (text, roff or html). Default: text.
+-b: Use the specified browser. Implies html mode.
+
+If stdout is not associated with a terminal device, the command
+dumps the man page to stdout and exits.
 
-If stdout associated with a terminal device, output is piped to
-$PAGER. If $PAGER is unset, less(1) is assumed.
+Otherwise, it tries to display the manual page as follows. In text
+mode, plain text is piped to $PAGER. In roff mode, the roff output
+is filtered through nroff, then piped to $PAGER. For both formats,
+if $PAGER is unset, less(1) is assumed.
+
+In html mode, html output is written to a temporary file, and this
+file is displayed as a page in the web browser. If -b is not given,
+the command stored in the $BROWSER environment variable is executed
+with the path to the temporary file as an argument. If $BROWSER is
+unset, elinks(1) is assumed.
+
+It is recommended to specify the output format with -m as the default
+mode might change in future versions of gsu.
 '
 
+_gsu_read_line()
+{
+       local -n p="$1"
+       local l OIFS="$IFS"
+
+       IFS=
+       read -r l || return
+       IFS="$OIFS"
+       p="$l"
+}
+
+_gsu_change_roffify_state()
+{
+       local -n statep="$1"
+       local new_state="$2"
+       local old_state="$statep"
+
+       [[ "$old_state" == "$new_state" ]] && return 0
+
+       case "$old_state" in
+       text);;
+       example) printf '.EE\n';;
+       enum) printf '.RE\n';;
+       esac
+       case "$new_state" in
+       text);;
+       example) printf '.EX\n';;
+       enum) printf '.RS 2\n';;
+       esac
+
+       statep="$new_state"
+       return 1
+}
+
+_gsu_print_protected_roff_line()
+{
+       local line="$1"
+       local -i n=0
+
+       while [[ "${line:$n:1}" == ' ' ]]; do
+               let n++
+       done
+       line="${line:$n}"
+       printf '\\&%s\n' "${line//\\/\\\\}"
+}
+
+_gsu_roffify_maindoc()
+{
+       local state='text' TAB='        '
+       local line next_line
+       local -i n
+
+       _gsu_read_line 'line' || return
+       while _gsu_read_line next_line; do
+               if [[ "$next_line" =~ ^(----|====|~~~~) ]]; then # heading
+                       printf '.SS %s\n' "$line"
+                       _gsu_read_line line || return
+                       _gsu_change_roffify_state 'state' 'text'
+                       continue
+               fi
+               if [[ "${line:0:1}" == "$TAB" ]]; then # example
+                       _gsu_change_roffify_state 'state' 'example'
+                       printf '%s\n' "$line"
+                       line="$next_line"
+                       continue
+               fi
+               n=0
+               while [[ "${line:$n:1}" == ' ' ]]; do
+                       let n++
+               done
+               line=${line:$n};
+               if [[ "${line:0:1}" == '*' ]]; then # enum
+                       line=${line#\*}
+                       _gsu_change_roffify_state 'state' 'enum'
+                       printf '\n\(bu %s\n' "$line"
+                       line="$next_line"
+                       continue
+               fi
+               if [[ "$line" =~ ^$ ]]; then # new paragraph
+                       _gsu_change_roffify_state 'state' 'text'
+                       printf '.PP\n'
+               else
+                       _gsu_print_protected_roff_line "$line"
+               fi
+               line="$next_line"
+       done
+       _gsu_print_protected_roff_line "$line"
+}
+
+_gsu_extract_maindoc()
+{
+       sed -e '1,/^#\{70,\}/d' -e '/^#\{70,\}/,$d' -e 's/^# *//' -e 's/^#//g' "$0"
+}
+
+_gsu_roffify_cmds()
+{
+       local line cmd= desc= state='text' TAB='        '
+
+       while _gsu_read_line line; do
+               if [[ "${line:0:1}" != '#' ]]; then # com_foo()
+                       line="${line#com_}"
+                       cmd="${line%()}"
+                       continue
+               fi
+               line="${line####}"
+               if [[ "$line" =~ ^[[:space:]]*$ ]]; then
+                       printf '.PP\n'
+                       _gsu_change_roffify_state 'state' 'text'
+                       continue
+               fi
+               if [[ -n "$cmd" ]]; then # desc or usage
+                       if [[ -z "$desc" ]]; then # desc
+                               desc="$line"
+                               continue
+                       fi
+                       # usage
+                       _gsu_change_roffify_state 'state' 'text'
+                       printf '\n.SS %s \\- %s\n' "$cmd" "$desc"
+                       printf '\n.I %s\n'  "$line"
+                       cmd=
+                       desc=
+                       continue
+               fi
+               line="${line# }"
+               if [[ "${line:0:1}" == "$TAB" ]]; then
+                       _gsu_change_roffify_state 'state' 'example'
+                       _gsu_print_protected_roff_line "$line"
+                       continue
+               fi
+               if [[ "$line" == -*:* ]]; then
+                       _gsu_change_roffify_state 'state' 'enum'
+                       printf '.PP\n.B %s:\n' "${line%%:*}"
+                       _gsu_print_protected_roff_line "${line#*:}"
+                       continue
+               fi
+               _gsu_print_protected_roff_line "$line"
+       done
+}
+
+_gsu_roffify_autocmd()
+{
+       local cmd="$1" help_txt="$2"
+
+       {
+               printf 'com_%s()\n' "$cmd"
+               sed -e 's/^/## /g' <<< "$help_txt"
+       } | _gsu_roffify_cmds
+}
+
+_gsu_roff_man()
+{
+       local name="$1" sect_num="$2" month_year="$3" pkg="$4" sect_name="$5"
+       local purpose="$6"
+       local ere
+
+       cat << EOF
+.TH "${name^^}" "$sect_num" "$month_year" "$pkg" "$sect_name"
+.SH NAME
+$name \- $purpose
+.SH SYNOPSIS
+.B $name
+\fI\,<subcommand>\/\fR [\fI\,<options>\/\fR] [\fI\,<arguments>\/\fR]
+.SH DESCRIPTION
+EOF
+       _gsu_extract_maindoc | _gsu_roffify_maindoc
+
+       printf '\n.SH "GENERIC SUBCOMMANDS"\n'
+       printf 'The following commands are automatically created by gsu\n'
+       _gsu_roffify_autocmd "help" "$_gsu_help_txt"
+       _gsu_roffify_autocmd "man" "$_gsu_man_txt"
+       _gsu_roffify_autocmd "prefs" "$_gsu_prefs_txt"
+       _gsu_roffify_autocmd "complete" "$_gsu_complete_txt"
+
+       printf '\n.SH "LIST OF SUBCOMMANDS"\n'
+       printf 'Each command has its own set of options as described below.\n'
+
+       _gsu_get_command_regex
+       ere="$result"
+       # only consider lines in the comment of the function
+       sed -nEe '/'"$ere"'/,/^[^#]/p' "$0" | _gsu_roffify_cmds
+}
+
+_gsu_file_mtime()
+{
+       local file="$1"
+       result="$(find "$file" -printf '%TB %TY' 2>/dev/null)" # GNU
+       (($? == 0)) && [[ -n "$result" ]] && return
+       result="$(stat -f %Sm -t '%B %Y' "$file" 2>/dev/null)" # BSD
+       (($? == 0)) && [[ -n "$result" ]] && return
+       result="$(date '+%B %Y' 2>/dev/null)" # POSIX
+       (($? == 0)) && [[ -n "$result" ]] && return
+       result='[unknown date]'
+}
+
 com_man()
 {
        local equal_signs="=================================================="
        local minus_signs="--------------------------------------------------"
-       local com num pager='cat'
+       local filter='cat' pager='cat' browser=${BROWSER:-elinks} tmpfile=
+       local com num isatty pipeline
 
-       _gsu_isatty && pager="${PAGER:-less}"
+       gsu_getopts "$_gsu_man_options"
+       eval "$result"
+       ((ret < 0)) && return
+       if [[ -n "$o_b" ]]; then
+               o_m='html'
+               browser="$o_b"
+       elif [[ -z "$o_m" ]]; then
+               o_m='text'
+       fi
+
+       _gsu_isatty && isatty='true' || isatty='false'
+       if [[ "$o_m" == 'roff' ]]; then
+               if [[ "$isatty" == 'true' ]]; then
+                       filter='nroff -Tutf8 -mandoc'
+                       pager="${PAGER:-less}"
+               fi
+       elif [[ "$o_m" == 'text' ]]; then
+               if [[ "$isatty" == 'true' ]]; then
+                       pager="${PAGER:-less}"
+               fi
+       elif [[ "$o_m" == 'html' ]]; then
+               filter='groff -T html -m man'
+               if [[ "$isatty" == 'true' ]]; then
+                       gsu_make_tempfile "gsu_html_man.XXXXXX.html"
+                       ((ret < 0)) && return || tmpfile="$result"
+                       trap "rm -f $tmpfile" RETURN EXIT
+               fi
+       fi
        [[ "$pager" == 'less' ]] && export LESS=${LESS-RI}
+       case "$o_m" in
+       roff|html)
+               _gsu_file_mtime "$0"
+               _gsu_roff_man "$gsu_name" '1' "$result" \
+                       "${gsu_package-${gsu_name^^}(1)}" \
+                       "User Commands" "${gsu_banner_txt}" \
+               | $filter | {
+                       if [[ -n "$tmpfile" ]]; then
+                               cat > "$tmpfile"
+                       else
+                               $pager
+                       fi
+               }
+               if (($? != 0)); then
+                       ret=-$E_GSU_XCMD
+                       result="filter: $filter"
+                       return
+               fi
+               if [[ -n "$tmpfile" ]]; then
+                       ret=-$E_GSU_XCMD
+                       result="$browser"
+                       "$browser" "$tmpfile" || return
+               fi
+               ret=$GSU_SUCCESS
+               return
+               ;;
+       text) ;;
+       "") ;;
+       *)
+               ret=-$E_GSU_INVAL
+               result="$o_m"
+               return
+       esac
        {
        echo "$gsu_name (_${gsu_banner_txt}_) manual"
        echo "${equal_signs:0:${#gsu_name} + ${#gsu_banner_txt} + 16}"
        echo
-
-       sed -e '1,/^#\{70,\}/d' -e '/^#\{70,\}/,$d' "$0" -e 's/^# *//'
+       _gsu_extract_maindoc
        echo "----"
        echo
        echo "$gsu_name usage"