# punkargs_punknative.tcl - PROTOTYPE (companion to punkargs_to_doctools.tcl) # Punk-native documentation pipeline: generate html + markdown for one package # directly from its punk::args definitions, faithful to the punk s-style # synopsis conventions ([...] optionals, placeholders) - bypassing # doctools entirely (doctools stays Tcl-standard; no man-page output here). # # usage: tclsh punkargs_punknative.tcl ?-boxmap none|light? ?-assets embed|link|none? # -boxmap none (default): faithful heavy box-drawing glyphs # -boxmap light: map heavy/mixed box chars to the light set (portability # fallback for viewers with only minimal mono fonts; _lightbox suffix) # -assets embed (default): data-uri the pinned punkdoc-mono web font # (fontprep/punkdoc-mono.woff2) into the page - fully self-contained # single file; every glyph (incl. octants/legacy computing) renders # identically on any machine (~106KB page overhead) # -assets link: reference punkdoc-mono.woff2 relatively and copy it to # - one cached font shared by a whole generated doc set # -assets none: no font shipped; rely on the viewer's installed fonts # via the css stack (octants/legacy-computing glyphs may be missing) # writes: /_punknative.html terminal-faithful rendering: # punk::args::usage ANSI output converted to styled HTML spans # (dark terminal theme, box-drawing tables, colours, italics) # /_punknative.md GitHub-flavoured markdown: # punk-style synopsis in code fences (ansistripped - exact # s-style text), summary/help prose, argument tables in # argspace order (GFM tables; no colour - github sanitizes # inline styles, so md is the plain-but-faithful skeleton) # # The ansi2html converter here is deliberately minimal (SGR subset punk::args # output uses: reset/bold/dim/italic/underline/strike/reverse, 16-colour, # 256-colour, truecolour). If adopted, it belongs in punk::ansi as a proper # public api (would also enable 'screenshots' of arbitrary repl output in docs). lassign [split [info tclversion] .] tcl_major tcl_minor #script lives at /src/scriptapps/tools/punkargs_punknative.tcl set project_root [file dirname [file dirname [file dirname [file dirname [file dirname [file normalize [info script]/__]]]]]] if {![file isdirectory $project_root/src/bootsupport/modules]} { puts stderr "cannot locate /src/bootsupport/modules from script location (derived project_root: $project_root)" exit 2 } set original_tmlist [tcl::tm::list] tcl::tm::remove {*}$original_tmlist tcl::tm::add [file normalize $project_root/src/bootsupport/modules] tcl::tm::add [file normalize $project_root/src/bootsupport/modules_tcl$tcl_major] tcl::tm::add {*}[lreverse $original_tmlist] package require platform set arch [platform::generic] foreach libdir [list [file normalize $project_root/src/bootsupport/lib] [file normalize $project_root/src/bootsupport/lib/tcl$tcl_major/$arch]] { if {$libdir ni $::auto_path} {lappend ::auto_path $libdir} } package require punk::args package require punk::lib ;#punk::args::lib::tstr placeholder resolution uses punk::lib::undent package require punk::ansi lassign $argv pkgname outdir #-boxmap: heavy box-drawing glyphs (U+2501 etc.) are the punk default and #render correctly PROVIDED the css font stack is applied to the pre element #itself (the UA stylesheet's 'pre {font-family:monospace}' overrides body #inheritance - the original overlong-border bug). The stack includes a #heavy-box-capable font on every mainstream platform (Consolas on windows, #DejaVu on linux, Menlo on macos), so faithful heavy output is the default. #-boxmap light maps heavy/mixed glyphs to the light set (universal coverage #in even minimal mono fonts like Courier New) as a portability fallback. #Verified 2026-07-13: heavy renders aligned in chrome + firefox with the #pre-level font stack. set boxmap none set assets embed foreach {k v} [lrange $argv 2 end] { switch -- $k { -boxmap { if {$v ni {light none}} { puts stderr "-boxmap must be light or none (got '$v')" exit 2 } set boxmap $v } -assets { if {$v ni {embed link none}} { puts stderr "-assets must be embed, link or none (got '$v')" exit 2 } set assets $v } default { puts stderr "unknown option '$k' (known: -boxmap -assets)" exit 2 } } } if {$pkgname eq "" || $outdir eq ""} { puts stderr "usage: punkargs_punknative.tcl ?-boxmap light|none?" exit 2 } set pkgversion [package require $pkgname] file mkdir $outdir # --------------------------------------------------------------------------- # minimal ansi (SGR) -> html converter # --------------------------------------------------------------------------- namespace eval ansi2html { #16-colour palette (vscode-ish terminal defaults) variable pal16 { #000000 #cd3131 #0dbc79 #e5e510 #2472c8 #bc3fbc #11a8cd #e5e5e5 #666666 #f14c4c #23d18b #f5f543 #3b8eea #d670d6 #29b8db #ffffff } variable default_fg "#d4d4d4" #terminal backdrop for rendered blocks: BLACK. overtype::renderspace #overlay output uses explicit SGR 40 (black bg) and art/example helpers #treat 'default background' as the terminal's - on a black pre they blend #exactly as in a black terminal (a grey pre showed contrasting black bars #inside example blocks). The page body stays lighter for contrast. variable default_bg "#000000" proc color256 {n} { variable pal16 if {$n < 16} {return [lindex $pal16 $n]} if {$n <= 231} { set n [expr {$n - 16}] set levels {0 95 135 175 215 255} set r [lindex $levels [expr {$n / 36}]] set g [lindex $levels [expr {($n % 36) / 6}]] set b [lindex $levels [expr {$n % 6}]] return [format "#%02x%02x%02x" $r $g $b] } set v [expr {8 + 10 * ($n - 232)}] return [format "#%02x%02x%02x" $v $v $v] } proc fresh_state {} { return [dict create bold 0 dim 0 italic 0 underline 0 strike 0 reverse 0 fg "" bg ""] } #apply one SGR sequence's parameter list to a state dict proc apply_params {state params} { variable pal16 if {![llength $params]} {set params 0} for {set i 0} {$i < [llength $params]} {incr i} { set p [lindex $params $i] #tolerate leading zeros / empty params if {$p eq ""} {set p 0} set p [scan $p %d] switch -- $p { 0 {set state [fresh_state]} 1 {dict set state bold 1} 2 {dict set state dim 1} 3 {dict set state italic 1} 4 {dict set state underline 1} 7 {dict set state reverse 1} 9 {dict set state strike 1} 22 {dict set state bold 0; dict set state dim 0} 23 {dict set state italic 0} 24 {dict set state underline 0} 27 {dict set state reverse 0} 29 {dict set state strike 0} 39 {dict set state fg ""} 49 {dict set state bg ""} 38 - 48 { set target [expr {$p == 38 ? "fg" : "bg"}] set mode [lindex $params [incr i]] if {$mode == 5} { dict set state $target [color256 [lindex $params [incr i]]] } elseif {$mode == 2} { set r [lindex $params [incr i]] set g [lindex $params [incr i]] set b [lindex $params [incr i]] dict set state $target [format "#%02x%02x%02x" $r $g $b] } } default { if {$p >= 30 && $p <= 37} { dict set state fg [lindex $pal16 [expr {$p - 30}]] } elseif {$p >= 90 && $p <= 97} { dict set state fg [lindex $pal16 [expr {$p - 90 + 8}]] } elseif {$p >= 40 && $p <= 47} { dict set state bg [lindex $pal16 [expr {$p - 40}]] } elseif {$p >= 100 && $p <= 107} { dict set state bg [lindex $pal16 [expr {$p - 100 + 8}]] } #other params ignored } } } return $state } proc state_css {state} { variable default_fg variable default_bg set fg [dict get $state fg] set bg [dict get $state bg] if {[dict get $state reverse]} { lassign [list [expr {$bg eq "" ? $default_bg : $bg}] [expr {$fg eq "" ? $default_fg : $fg}]] fg bg } set css [list] if {$fg ne ""} {lappend css "color:$fg"} if {$bg ne ""} {lappend css "background-color:$bg"} if {[dict get $state bold]} {lappend css "font-weight:bold"} if {[dict get $state dim]} {lappend css "opacity:.62"} if {[dict get $state italic]} {lappend css "font-style:italic"} set deco [list] if {[dict get $state underline]} {lappend deco underline} if {[dict get $state strike]} {lappend deco line-through} if {[llength $deco]} {lappend css "text-decoration:[join $deco { }]"} return [join $css ";"] } proc html_escape {text} { return [string map {& & < < > >} $text] } #map heavy and mixed-weight box-drawing chars to the light set (universal #monospace font coverage - avoids browser glyph-fallback width blowout) variable lightboxmap { ━ ─ ┃ │ ┏ ┌ ┓ ┐ ┗ └ ┛ ┘ ┣ ├ ┫ ┤ ┳ ┬ ┻ ┴ ╋ ┼ ┡ ├ ┩ ┤ ╇ ┼ ┢ ├ ┪ ┤ ╈ ┼ ┠ ├ ┨ ┤ ┯ ┬ ┷ ┴ ┿ ┼ ╂ ┼ } proc to_lightbox {text} { variable lightboxmap return [string map $lightboxmap $text] } #convert ANSI text to html (span-styled, for use inside
)
    proc convert {text} {
        set out ""
        set state [fresh_state]
        #split_codes returns alternating plaintext,codes,plaintext,... (codes
        #element may contain several adjacent escape sequences)
        set parts [punk::ansi::ta::split_codes $text]
        set is_pt 1
        foreach part $parts {
            if {$is_pt} {
                if {$part ne ""} {
                    set css [state_css $state]
                    if {$css ne ""} {
                        append out "[html_escape $part]"
                    } else {
                        append out [html_escape $part]
                    }
                }
            } else {
                #apply each SGR sequence in the code chunk; ignore non-SGR codes
                foreach {m body} [regexp -all -inline {\x1b\[([0-9;:]*)m} $part] {
                    set state [apply_params $state [split [string map {: ;} $body] \;]]
                }
            }
            set is_pt [expr {!$is_pt}]
        }
        return $out
    }
}

# ---------------------------------------------------------------------------
# shared spec helpers (subset of the doctools converter's)
# ---------------------------------------------------------------------------
proc optvalname {optname} {return [string trimleft $optname -]}
proc is_solo {arginfo} {
    return [expr {"none" in [split [lindex [dict get $arginfo -type] 0] |]}]
}
proc typedisplay {arginfo} {
    set t [dict get $arginfo -type]
    return [lindex [split [lindex $t 0] |] 0]
}
proc md_escape {text} {
    #escape for GFM table cells: pipes and newlines
    set text [punk::ansi::ansistrip $text]
    set text [string map {| \\| \r\n " " \n " " \r " "} $text]
    return $text
}
proc md_prose {text} {
    #reflow to paragraphs: source indentation must not survive - 4-space
    #indented lines are code blocks in markdown (accidental 
 rendering)
    set text [punk::ansi::ansistrip $text]
    set paras [list]
    set current ""
    foreach ln [split $text \n] {
        if {[string trim $ln] eq ""} {
            if {$current ne ""} {lappend paras $current; set current ""}
        } else {
            append current [string trim $ln] " "
        }
    }
    if {$current ne ""} {lappend paras $current}
    return [join $paras "\n\n"]
}
#punk s-style synopsis text (ansistripped), minus the '# summary' preamble line;
#'## FORM n' headers kept only for multi-form commands
proc punk_synopsis_text {id nforms} {
    set s [punk::ansi::ansistrip [punk::args::synopsis $id]]
    set keep [list]
    foreach ln [split $s \n] {
        if {[string match "# *" $ln]} {continue}
        if {[string match "## FORM*" $ln] && $nforms < 2} {continue}
        lappend keep $ln
    }
    return [join $keep \n]
}

# ---------------------------------------------------------------------------
# gather
# ---------------------------------------------------------------------------
punk::args::update_definitions [list ::$pkgname]
set ids [lsort [punk::args::get_ids ::${pkgname}::*]]
set cmd_ids [list]
foreach id $ids {
    set tail [string range $id [string length ::${pkgname}::] end]
    if {[string first :: $tail] < 0} {lappend cmd_ids $id}
}
if {![llength $cmd_ids]} {
    puts stderr "no punk::args ids found for ::${pkgname}::*"
    exit 1
}
#suffix distinguishes these from doctools-engine renderings of the same package
set pkgfile "[string map {:: _} $pkgname]_punknative"
if {$boxmap eq "light"} {
    append pkgfile "_lightbox"
}
puts "punk-native docgen: [llength $cmd_ids] command definitions for $pkgname $pkgversion"

# ---------------------------------------------------------------------------
# html: terminal-faithful - ansi2html of punk::args::usage per command
# ---------------------------------------------------------------------------
#pinned web font (see fontprep/README.md): guarantees every glyph in the punk
#domain - incl. unicode 16 octants and legacy computing blocks that no
#commonly-installed font provides. Variable (wght 200-700) so bold spans use
#real bold. OFL-subset of Cascadia Mono renamed punkdoc-mono (RFN condition).
set fontfile [file join [file dirname [file dirname [file normalize [info script]/__]]] fontprep punkdoc-mono.woff2]
set fontface ""
if {$assets ne "none"} {
    if {![file exists $fontfile]} {
        puts stderr "WARNING: -assets $assets requested but $fontfile not found - falling back to -assets none"
        set assets none
    } else {
        if {$assets eq "embed"} {
            set fd [open $fontfile rb]
            set fontb64 [binary encode base64 [read $fd]]
            close $fd
            set fontsrc "data:font/woff2;base64,$fontb64"
        } else {
            #link mode: ship the font next to the page(s) - shared+cached
            file copy -force $fontfile [file join $outdir punkdoc-mono.woff2]
            set fontsrc "punkdoc-mono.woff2"
        }
        set fontface "@font-face {font-family:\"punkdoc-mono\"; src:url($fontsrc) format(\"woff2\"); font-weight:200 700; font-display:block;}\n"
    }
}

set html ""
append html "\n\n"
append html "$pkgname $pkgversion - punk::args reference\n"
append html "\n"
append html "

$pkgname $pkgversion

\n" append html "

Command reference generated from punk::args definitions (terminal-faithful rendering).

\n" #module/package-level info when @package directives are registered (none in #older module versions - the hook is here for when dev modules surface them) set package_notes [list] foreach id $cmd_ids { set spec [punk::args::get_spec $id] #package_info key absent in older punk::args spec dicts (pre-@package) if {![dict exists $spec package_info]} {break} set pinfo [dict get $spec package_info] if {[llength $pinfo] && $pinfo ni $package_notes} { lappend package_notes $pinfo } } foreach pinfo $package_notes { append html "

package: [ansi2html::html_escape [punk::ansi::ansistrip $pinfo]]

\n" } #command index navigation (doctools-style jump list) append html "\n" #helper: one converted usage block. #-trim 1: keep only the Arg table (from its header separator down) - used for #per-form blocks so they don't repeat the whole command/description/synopsis #panel and drown the per-form differences (renderspace output lines are #colour-self-contained, so slicing at a line boundary is safe) proc usage_block {id args} { global boxmap set trim 0 set idx [lsearch -exact $args -trim] if {$idx >= 0} { set trim [lindex $args $idx+1] set args [lreplace $args $idx $idx+1] } if {[catch {punk::args::usage {*}$args $id} u]} { return "

(no usage rendering: [ansi2html::html_escape $u])

\n" } if {$trim} { set lines [split $u \n] set argidx -1 set i 0 foreach ln $lines { if {[string match "┃Arg *" [punk::ansi::ansistrip $ln]]} { set argidx $i break } incr i } if {$argidx > 0} { #include the separator line above the Arg header as the top border set u [join [lrange $lines $argidx-1 end] \n] } } if {$boxmap eq "light"} { set u [ansi2html::to_lightbox $u] } return "
[ansi2html::convert $u]
\n" } foreach id $cmd_ids { set cmdname [string trimleft $id :] append html "

$cmdname

\n" set aliastarget [punk::args::get_idalias $id] if {$aliastarget ne ""} { append html "

Takes the same arguments as [ansi2html::html_escape [string trimleft $aliastarget :]] (shared argument definition).

\n" } #the default usage table shows description + all form synopses, but for #multiform commands its Arg table covers only the default form - render #an additional per-form ARG TABLE (trimmed - no repeated header panel) #for each form that has arguments (mirrors 'i -form N '). append html [usage_block $id] set spec [punk::args::get_spec $id] set formnames [dict get $spec form_names] if {[llength $formnames] > 1} { foreach formname $formnames { set form [dict get $spec FORMS $formname] if {![dict size [dict get $form ARG_INFO]]} { #form takes no arguments - the synopsis line says it all continue } append html "

form: [ansi2html::html_escape $formname]

\n" append html [usage_block $id -form $formname -trim 1] } } } append html "\n" set fd [open [file join $outdir $pkgfile.html] w] fconfigure $fd -translation lf -encoding utf-8 puts -nonewline $fd $html close $fd puts "wrote [file join $outdir $pkgfile.html] ([string length $html] bytes)" # --------------------------------------------------------------------------- # markdown: GFM - punk-style synopsis fences + argument tables (argspace order) # (-boxmap only affects the html rendering - only the default run writes md, # so an opt-in heavybox run doesn't duplicate an identical file) # --------------------------------------------------------------------------- if {$boxmap ne "none"} { exit 0 } proc md_argrow {argname arginfo} { set type [md_escape [typedisplay $arginfo]] if {[is_solo $arginfo]} {set type "(solo flag)"} set default "" if {[dict exists $arginfo -default]} {set default [md_escape [dict get $arginfo -default]]} if {$default eq ""} {set default " "} set multi " " if {[dict exists $arginfo -multiple] && [dict get $arginfo -multiple]} {set multi "yes"} set help "" if {[dict exists $arginfo -help]} {set help [md_escape [dict get $arginfo -help]]} set choices [list] if {[dict exists $arginfo -choices]} {lappend choices {*}[dict get $arginfo -choices]} if {[dict exists $arginfo -choicegroups]} { dict for {grp members} [dict get $arginfo -choicegroups] {lappend choices {*}$members} } if {[llength $choices]} { append help " Choices: [md_escape [join $choices {, }]]." } return "| `[md_escape $argname]` | $type | `$default` | $multi | $help |" } set md "" append md "# $pkgname $pkgversion\n\n" append md "Command reference generated from punk::args definitions (punk s-style synopses).\n" foreach id $cmd_ids { set spec [punk::args::get_spec $id] set cmd_info [dict get $spec cmd_info] set cmdname [string trimleft $id :] append md "\n---\n\n## $cmdname\n\n" set aliastarget [punk::args::get_idalias $id] if {[dict exists $cmd_info -summary] && [dict get $cmd_info -summary] ne ""} { append md "*[md_escape [dict get $cmd_info -summary]]*\n\n" } set nforms [llength [dict get $spec form_names]] append md "```\n[punk_synopsis_text $id $nforms]\n```\n" if {$aliastarget ne ""} { set atail [string trimleft $aliastarget :] set aanchor [string map {:: -} [string tolower $atail]] append md "\nTakes the same arguments as \[`$atail`\](#$aanchor) (shared argument definition).\n" continue } if {[dict exists $cmd_info -help] && [dict get $cmd_info -help] ne ""} { append md "\n[md_prose [dict get $cmd_info -help]]\n" } foreach formname [dict get $spec form_names] { set form [dict get $spec FORMS $formname] if {$nforms > 1} { append md "\n**form: $formname**\n" } set leaders [dict get $form LEADER_NAMES] set opts [dict get $form OPT_NAMES] set vals [dict get $form VAL_NAMES] if {[llength $leaders] + [llength $opts] + [llength $vals] == 0} {continue} append md "\n| Arg | Type | Default | Multi | Help |\n" append md "| --- | --- | --- | --- | --- |\n" #argspace order: leaders, options, trailing values foreach argname $leaders { append md [md_argrow $argname [dict get $form ARG_INFO $argname]] \n } foreach optname $opts { append md [md_argrow $optname [dict get $form ARG_INFO $optname]] \n } foreach argname $vals { append md [md_argrow $argname [dict get $form ARG_INFO $argname]] \n } } } set fd [open [file join $outdir $pkgfile.md] w] fconfigure $fd -translation lf -encoding utf-8 puts -nonewline $fd $md close $fd puts "wrote [file join $outdir $pkgfile.md] ([string length $md] bytes)"