You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

556 lines
25 KiB

# 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, <type> placeholders) - bypassing
# doctools entirely (doctools stays Tcl-standard; no man-page output here).
#
# usage: tclsh punkargs_punknative.tcl <package> <outdir> ?-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
# <outdir> - 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: <outdir>/<pkg>_punknative.html terminal-faithful rendering:
# punk::args::usage ANSI output converted to styled HTML spans
# (dark terminal theme, box-drawing tables, colours, italics)
# <outdir>/<pkg>_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 <projectroot>/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 <projectroot>/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 <package> <outdir> ?-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 {& &amp; < &lt; > &gt;} $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 <pre>)
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 "<span style=\"$css\">[html_escape $part]</span>"
} 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 <pre> 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 "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n"
append html "<title>$pkgname $pkgversion - punk::args reference</title>\n"
append html "<style>\n"
append html $fontface
#fallback stack: fonts with FULL box-drawing coverage (incl. heavy +
#mixed-weight chars) first - Courier New (firefox's default mono) lacks the
#heavy set. lang=en above steers chrome away from CJK ambiguous-width
#(double-width) fallback for box chars when no stack font matches.
set fontstack "'Cascadia Mono','Cascadia Code','DejaVu Sans Mono','Noto Sans Mono','JetBrains Mono','Liberation Mono',Consolas,Menlo,monospace"
if {$fontface ne ""} {
set fontstack "'punkdoc-mono',$fontstack"
}
append html "body {background:#1e1e1e; color:$ansi2html::default_fg; font-family:$fontstack;}\n"
append html "h1,h2,h3 {font-family:inherit; color:#e5e510;}\n"
append html "nav.cmdindex {background:#000; padding:8px 12px; display:inline-block;}\n"
append html "nav.cmdindex a {color:#29b8db; text-decoration:none; display:inline-block; margin-right:1.5em;}\n"
append html "nav.cmdindex a:hover {text-decoration:underline;}\n"
#IMPORTANT: pre needs its own font-family - the UA stylesheet's
#'pre {font-family:monospace}' overrides inheritance from body, so without
#this the pre renders in the browser's default mono font regardless of the
#body stack (the original cause of overlong heavy-box border lines)
#punkdoc-mono cell geometry (see fontprep/README.md): advance is exactly
#0.5859375em - font-size 15.36px gives an integer 9px cell width (fractional
#advances round differently at styled-span boundaries -> vertical seams
#through block/border glyphs). Row pitch: browsers size inline BACKGROUNDS
#to the font's hhea content area = 1.1621em = 17.85px here; a fractional
#pitch (1.15em = 17.664px) put row boundaries at fractional positions and
#showed periodic hairline gaps through solid-bg runs (~every 2 lines).
#line-height:17px = integer pitch just under the bg height, so adjacent
#rows' backgrounds overlap ~0.85px - sealed at any zoom/dpi (cost: bg rows
#overpaint <1px of the row above's descenders - imperceptible in tables).
#If the font stack falls back past punkdoc-mono this exactness is lost but
#text-only content tolerates it.
append html "pre.punkterm {font-family:$fontstack; line-height:17px; font-size:15.36px; overflow-x:auto; padding:8px; background:$ansi2html::default_bg; font-variant-ligatures:none;}\n"
append html "hr {border:0; border-top:1px solid #444;}\n"
append html "</style></head><body>\n"
append html "<h1>$pkgname $pkgversion</h1>\n"
append html "<p>Command reference generated from punk::args definitions (terminal-faithful rendering).</p>\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 "<p>package: [ansi2html::html_escape [punk::ansi::ansistrip $pinfo]]</p>\n"
}
#command index navigation (doctools-style jump list)
append html "<nav class=\"cmdindex\">\n"
foreach id $cmd_ids {
set cmdname [string trimleft $id :]
set esc [ansi2html::html_escape $cmdname]
append html "<a href=\"#$esc\">$esc</a>\n"
}
append html "</nav>\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 "<p>(no usage rendering: [ansi2html::html_escape $u])</p>\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 "<pre class=\"punkterm\">[ansi2html::convert $u]</pre>\n"
}
foreach id $cmd_ids {
set cmdname [string trimleft $id :]
append html "<hr><h2 id=\"[ansi2html::html_escape $cmdname]\">$cmdname</h2>\n"
set aliastarget [punk::args::get_idalias $id]
if {$aliastarget ne ""} {
append html "<p>Takes the same arguments as <b>[ansi2html::html_escape [string trimleft $aliastarget :]]</b> (shared argument definition).</p>\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 <cmd>').
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 "<h3>form: [ansi2html::html_escape $formname]</h3>\n"
append html [usage_block $id -form $formname -trim 1]
}
}
}
append html "</body></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)"