| git.druid.rocks | index | druid520 | mp | mk/ | genconfdoc.pl |
mk/genconfdoc.pl
#!/usr/bin/env perl
# generates docs/reference/*-example.btft from this repo's own root
# *.conf.example/*.env.example files -- a single source of truth
# instead of the example files' own comments and the rendered docs
# slowly drifting apart. run as part of "mk . d"/"sh mk/d.sh", same as
# every other doc-generation step; NEVER hand-edit the resulting
# *-example.btft files directly, edit the source .example file and
# regenerate instead (matching every other "do not edit, generated"
# convention this tree already uses for btf's own html/man output).
#
# a source file is a sequence of blank-line-delimited CHUNKS. within
# one chunk, each line is one of:
# - a real, uncommented directive line (no leading "#" at all) --
# e.g. mpx.conf.example's own "SHELL=\"/bin/sh\"" -- always CODE.
# - a bare "#" (nothing after it) -- a paragraph break WITHIN the
# chunk's own prose, not a new chunk (a chunk can hold more than
# one paragraph under the same heading).
# - "# " followed by text that's still indented, or that looks like
# a KEY=value/KEY: directive -- an example embedded inside the
# surrounding explanation (e.g. "# REPO_main=git://...") -- CODE.
# - "# " followed by ordinary prose -- PROSE.
# consecutive PROSE lines join into one flowing paragraph (the same
# "newline within a paragraph is just a soft wrap" rule every OTHER
# page in this docs tree already relies on); consecutive CODE lines
# join into one [code] block, each line's own indentation preserved
# past the leading "# " it's stripped of.
#
# the first chunk's own first prose line, up to its first ". " or ":"
# (matching this docs tree's own existing "topic. explanation" heading
# style -- see mp.conf.example's own "hold (inside a...)"/"hooks. global
# keys..." for real examples of the convention being extracted here,
# not invented for this script), becomes that chunk's [h2] title, kept
# exactly as-authored (lowercase, no title-casing) to match house
# style. a chunk with no clean short phrase to extract (no "." or ":"
# within a reasonable prefix) renders with no heading at all, exactly
# like this docs tree's own hand-written pages already do for an
# opening/preamble paragraph.
use v5.16;
use strict;
use warnings;
sub h { my $s = shift; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; return $s; }
# a handful of common lead-in words read as a topic phrase by the same
# ".../ :" pattern a genuine heading uses (e.g. "example: export a var
# ..."), but are never actually meant as a section title of their own
# -- excluded explicitly rather than trying to out-clever every such
# case with a smarter pattern.
my %GENERIC_HEADING = map { $_ => 1 } qw(example examples note notes warning tip aside caveat);
sub extract_heading {
my ($line) = @_;
my $cand;
# (?<!\be\.g)(?<!\bi\.e)(?<!\betc): a period ending a common
# abbreviation ("e.g. cmake", "i.e. the default", "etc. and more")
# is not a sentence boundary -- confirmed the hard way, "build vs
# runtime deps: pkg_bdepend (build-only, e.g. cmake/autoconf)"
# extracted a heading truncated at "...e.g" before this guard.
if($line =~ /^(.{1,70}?)(?<!\be\.g)(?<!\bi\.e)(?<!\betc)\.\s/) { $cand = $1; }
elsif($line =~ /^([a-zA-Z][a-zA-Z0-9_* <>%!=,\/'-]{1,40}):(?:\s|$)/) { $cand = $1; }
return undef unless defined $cand;
return undef if $GENERIC_HEADING{lc $cand};
return $cand;
}
sub render_chunk {
my ($lines) = @_;
my @paras; # each: { type => 'prose'|'code', text => [...] }
for my $line (@$lines)
{
if($line !~ /^#/)
{
push @paras, { type => 'code', text => [] } unless @paras && $paras[-1]{type} eq 'code';
push @{$paras[-1]{text}}, $line;
next;
}
(my $rest = $line) =~ s/^#//;
if($rest =~ /^\s*$/)
{
push @paras, { type => 'break' };
next;
}
# the actual, consistent authoring convention across every one
# of these files (confirmed directly, not assumed): PROSE is
# always "# text" -- exactly one space after the hash, nothing
# more. an embedded example/directive is one of:
# "#text" no separating space at all (mpx.env.example's
# own commented-out "#export FOO=...", "#PATH=..."),
# "# text" a real space PLUS further indentation (mp.conf.
# example's own "# REPO_main=git://..."), or
# "# KEY=..." a normal single space, but the text itself is
# an all-caps KEY=/KEY (a definition-list TERM
# line, e.g. hooks.conf.example's own
# "# IF_USE=<flag> <flag> ..." heading for the
# indented explanation that follows it).
my $is_code;
if($rest !~ /^ /) { $is_code = 1; }
elsif($rest =~ /^ /) { $is_code = 1; $rest =~ s/^ //; }
else
{
$rest =~ s/^ //;
$is_code = ($rest =~ /^[A-Z][A-Z0-9_]*[+-]?[=:]/) ? 1 : 0;
}
if($is_code)
{
push @paras, { type => 'code', text => [] } unless @paras && $paras[-1]{type} eq 'code';
push @{$paras[-1]{text}}, $rest;
}
else
{
push @paras, { type => 'prose', text => [] } unless @paras && $paras[-1]{type} eq 'prose';
push @{$paras[-1]{text}}, $rest;
}
}
# pull the heading candidate off the very first prose paragraph's
# own first line, WITHOUT consuming that whole line -- the rest of
# it (and everything else in that paragraph) still renders as body.
my $heading;
for my $p (@paras)
{
next unless $p->{type} eq 'prose';
$heading = extract_heading($p->{text}[0]);
last;
}
my $out = '';
$out .= "[h2]" . h($heading) . "[e]\n\n" if defined $heading;
for my $p (@paras)
{
if($p->{type} eq 'break') { next; }
elsif($p->{type} eq 'code')
{
$out .= "[code]\n" . join("\n", @{$p->{text}}) . "\n[e]\n\n";
}
else
{
$out .= h(join(' ', @{$p->{text}})) . "\n\n";
}
}
return $out;
}
sub convert {
my ($src, $title, $seename) = @_;
open(my $fh, '<', $src) or die "err: cannot open $src: $!\n";
my @lines = map { chomp; $_ } <$fh>;
close($fh);
my @chunks;
my @cur;
for my $line (@lines)
{
if($line eq '')
{
push @chunks, [@cur] if @cur;
@cur = ();
}
else
{
push @cur, $line;
}
}
push @chunks, [@cur] if @cur;
my $body = '';
$body .= render_chunk($_) for @chunks;
return <<EOF;
[table class="topnav"]
[tr]
[td class="logotab"]mp[e]
[td][see name="index"]index[e][e]
[td][see name="guide/index"]guide[e][e]
[td][see name="reference/index"]reference[e][e]
[e]
[e]
[h1]${title}[e]
[quote class="note"]
generated from $src -- do not edit this page directly, edit that file and regenerate ("mk . d" / "sh mk/d.sh") instead. see [see name="reference/config"]reference/config[e] for the narrative tour this page doesn't replace.
[e]
$body
EOF
}
my %files = (
'mp.conf.example' => ['docs/reference/mp-conf-example.btft', 'mp.conf.example'],
'mpx.conf.example' => ['docs/reference/mpx-conf-example.btft', 'mpx.conf.example'],
'mpx.env.example' => ['docs/reference/mpx-env-example.btft', 'mpx.env.example'],
'patches.conf.example' => ['docs/reference/patches-conf-example.btft', 'patches.conf.example'],
'hooks.conf.example' => ['docs/reference/hooks-conf-example.btft', 'hooks.conf.example'],
);
for my $src (sort keys %files)
{
my ($dest, $title) = @{$files{$src}};
my $content = convert($src, $title);
open(my $out, '>', $dest) or die "err: cannot write $dest: $!\n";
print $out $content;
close($out);
print "generated $dest from $src\n";
}