File Coverage

blib/lib/App/DocKnot/Generate.pm
Criterion Covered Total %
statement 180 186 96.7
branch 24 30 80.0
condition 6 9 66.6
subroutine 24 24 100.0
pod 4 4 100.0
total 238 253 94.0


line stmt bran cond sub pod time code
1             # Generate human-readable documentation from package metadata.
2             #
3             # This is the implementation of the docknot generate command, which uses
4             # templates to support generation of various documentation files based on
5             # package metadata.
6             #
7             # SPDX-License-Identifier: MIT
8              
9             ##############################################################################
10             # Modules and declarations
11             ##############################################################################
12              
13             package App::DocKnot::Generate 3.01;
14              
15 5     5   290517 use 5.024;
  5         39  
16 5     5   23 use autodie;
  5         8  
  5         34  
17 5     5   22255 use parent qw(App::DocKnot);
  5         10  
  5         35  
18 5     5   234 use warnings;
  5         9  
  5         143  
19              
20 5     5   1193 use App::DocKnot::Config;
  5         9  
  5         164  
21 5     5   32 use Carp qw(croak);
  5         8  
  5         213  
22 5     5   2250 use Template;
  5         78190  
  5         159  
23 5     5   1989 use Text::Wrap qw(wrap);
  5         10799  
  5         12606  
24              
25             # Default output files for specific templates.
26             my %DEFAULT_OUTPUT = (
27             'readme' => 'README',
28             'readme-md' => 'README.md',
29             );
30              
31             ##############################################################################
32             # Generator functions
33             ##############################################################################
34              
35             # The internal helper object methods in this section are generators. They
36             # return code references intended to be passed into Template Toolkit as code
37             # references so that they can be called inside templates, incorporating data
38             # from the App::DocKnot configuration or the package metadata.
39              
40             # Returns code to center a line in $self->{width} characters given the text of
41             # the line. The returned code will take a line of text and return that line
42             # with leading whitespace added as required.
43             #
44             # $self - The App::DocKnot::Generate object
45             #
46             # Returns: Code reference to a closure that uses $self->{width} for width
47             sub _code_for_center {
48 34     34   79 my ($self) = @_;
49             my $center = sub {
50 42     42   95 my ($text) = @_;
51 42         97 my $space = $self->{width} - length($text);
52 42 50       95 if ($space <= 0) {
53 0         0 return $text;
54             } else {
55 42         225 return q{ } x int($space / 2) . $text;
56             }
57 34         170 };
58 34         99 return $center;
59             }
60              
61             # Returns code that formats the copyright notices for the package. The
62             # resulting code reference takes two parameter, the indentation level and an
63             # optional prefix for each line, and wraps the copyright notices accordingly.
64             # They will be wrapped with a four-space outdent and kept within
65             # $self->{width} columns.
66             #
67             # $self - The App::DocKnot::Generate object
68             # $copyrights_ref - A reference to a list of anonymous hashes, each with keys:
69             # holder - The copyright holder for that copyright
70             # years - The years of that copyright
71             #
72             # Returns: Code reference to a closure taking an indent level and an optional
73             # prefix and returning the formatted copyright notice
74             sub _code_for_copyright {
75 34     34   91 my ($self, $copyrights_ref) = @_;
76             my $copyright = sub {
77 14     14   44 my ($indent, $lead) = @_;
78 14   50     92 my $prefix = ($lead // q{}) . q{ } x $indent;
79 14         29 my $notice;
80 14         52 for my $copyright ($copyrights_ref->@*) {
81 24         51 my $holder = $copyright->{holder};
82 24         39 my $years = $copyright->{years};
83              
84             # Build the initial notice with the word copyright and the years.
85 24         57 my $text = 'Copyright ' . $copyright->{years};
86 24         53 local $Text::Wrap::columns = $self->{width} + 1;
87 24         37 local $Text::Wrap::unexpand = 0;
88 24         105 $text = wrap($prefix, $prefix . q{ } x 4, $text);
89              
90             # See if the holder fits on the last line. If so, add it there;
91             # otherwise, add another line.
92 24         3174 my $last_length;
93 24 50       86 if (rindex($text, "\n") == -1) {
94 24         36 $last_length = length($text);
95             } else {
96 0         0 $last_length = length($text) - rindex($text, "\n");
97             }
98 24 100       71 if ($last_length + length($holder) < $self->{width}) {
99 17         46 $text .= " $holder";
100             } else {
101 7         23 $text .= "\n" . $prefix . q{ } x 4 . $holder;
102             }
103 24         90 $notice .= $text . "\n";
104             }
105 14         39 chomp($notice);
106 14         53 return $notice;
107 34         168 };
108 34         91 return $copyright;
109             }
110              
111             # Returns code to indent each line of a paragraph by a given number of spaces.
112             # This is constructed as a method returning a closure so that its behavior can
113             # be influenced by App::DocKnot configuration in the future, but it currently
114             # doesn't use any configuration. It takes the indentation and an optional
115             # prefix to put at the start of each line.
116             #
117             # $self - The App::DocKnot::Generate object
118             #
119             # Returns: Code reference to a closure
120             sub _code_for_indent {
121 34     34   131 my ($self) = @_;
122             my $indent = sub {
123 122     122   274 my ($text, $space, $lead) = @_;
124 122   100     460 $lead //= q{};
125 122         930 my @text = split(m{\n}xms, $text);
126 122         255 return join("\n", map { $lead . q{ } x $space . $_ } @text);
  2562         5338  
127 34         124 };
128 34         80 return $indent;
129             }
130              
131             # Returns code that converts metadata text (which is assumed to be in
132             # Markdown) to text. This is not a complete Markdown formatter. It only
133             # supports the bits of markup that I've had some reason to use.
134             #
135             # This is constructed as a method returning a closure so that its behavior can
136             # be influenced by App::DocKnot configuration in the future, but it currently
137             # doesn't use any configuration.
138             #
139             # $self - The App::DocKnot::Generate object
140             #
141             # Returns: Code reference to a closure that takes a block of text and returns
142             # the coverted text
143             sub _code_for_to_text {
144 34     34   76 my ($self) = @_;
145             my $to_text = sub {
146 79     79   176 my ($text) = @_;
147              
148             # Remove triple backticks but escape all backticks inside them.
149 79         260 $text =~ s{ ``` \w* (\s .*?) ``` }{
150 35         76 my $text = $1;
151 35         47 $text =~ s{ [\`] }{``}xmsg;
152 35         153 $text;
153             }xmsge;
154              
155             # Remove backticks, but don't look at things starting with doubled
156             # backticks.
157 79         721 $text =~ s{ (?
158              
159             # Undo backtick escaping.
160 79         143 $text =~ s{ `` }{\`}xmsg;
161              
162             # Rewrite quoted paragraphs to have four spaces of additional
163             # indentation.
164 79         122 $text =~ s{
165             \n \n # start of paragraph
166             ( # start of the text
167             (> \s+) # quote mark on first line
168             \S [^\n]* \n # first line
169             (?: # all subsequent lines
170             \2 \S [^\n]* \n # start with the same prefix
171             )* # any number of subsequent lines
172             ) # end of the text
173             }{
174 1         5 my ($text, $prefix) = ($1, $2);
175 1         20 $text =~ s{ ^ \Q$prefix\E }{ }xmsg;
176 1         6 "\n\n" . $text;
177             }xmsge;
178              
179             # For each paragraph, remove URLs from all links, replacing them with
180             # numeric references, and accumulate the mapping of numbers to URLs in
181             # %urls. Then, add to the end of the paragraph the references and
182             # URLs.
183 79         105 my $ref = 1;
184 79         312 my @paragraphs = split(m{ \n\n }xms, $text);
185 79         143 for my $para (@paragraphs) {
186 362         418 my %urls;
187 362         651 while ($para =~ s{ \[ ([^\]]+) \] [(] (\S+) [)] }{$1 [$ref]}xms) {
188 12         41 $urls{$ref} = $2;
189 12         38 $ref++;
190             }
191 362 100       583 if (%urls) {
192 10         34 my @refs = map { "[$_] $urls{$_}" } sort { $a <=> $b }
  12         46  
  2         8  
193             keys(%urls);
194 10         46 $para .= "\n\n" . join("\n", q{}, @refs, q{});
195             }
196             }
197              
198             # Rejoin the paragraphs and return the result.
199 79         792 return join("\n\n", @paragraphs);
200 34         227 };
201 34         79 return $to_text;
202             }
203              
204             # Returns code that converts metadata text (which is assumed to be in
205             # Markdown) to thread. This is not a complete Markdown formatter. It only
206             # supports the bits of markup that I've had some reason to use.
207             #
208             # This is constructed as a method returning a closure so that its behavior can
209             # be influenced by App::DocKnot configuration in the future, but it currently
210             # doesn't use any configuration.
211             #
212             # $self - The App::DocKnot::Generate object
213             #
214             # Returns: Code reference to a closure that takes a block of text and returns
215             # the coverted thread
216             sub _code_for_to_thread {
217 34     34   91 my ($self) = @_;
218             my $to_thread = sub {
219 39     39   80 my ($text) = @_;
220              
221             # Escape all backslashes.
222 39         76 $text =~ s{ \\ }{\\\\}xmsg;
223              
224             # Rewrite triple backticks to \pre blocks and escape backticks inside
225             # them so that they're not turned into \code blocks.
226 39         74 $text =~ s{ ``` \w* (\s .*?) ``` }{
227 0         0 my $text = $1;
228 0         0 $text =~ s{ [\`] }{``}xmsg;
229 0         0 '\pre[' . $1 . ']';
230             }xmsge;
231              
232             # Rewrite backticks to \code blocks.
233 39         133 $text =~ s{ ` ([^\`]+) ` }{\\code[$1]}xmsg;
234              
235             # Undo backtick escaping.
236 39         59 $text =~ s{ `` }{\`}xmsg;
237              
238             # Rewrite all Markdown links into thread syntax.
239 39         128 $text =~ s{ \[ ([^\]]+) \] [(] (\S+) [)] }{\\link[$2][$1]}xmsg;
240              
241             # Rewrite long bullets. This is quite tricky since we have to grab
242             # every line from the first bulleted one to the point where the
243             # indentation stops.
244 39         107 $text =~ s{
245             ( # capture whole contents
246             ^ (\s*) # indent before bullet
247             [*] (\s+) # bullet and following indent
248             [^\n]+ \n # rest of line
249             (?: \s* \n )* # optional blank lines
250             (\2 [ ] \3) # matching indent
251             [^\n]+ \n # rest of line
252             (?: # one or more of
253             \4 # matching indent
254             [^\n]+ \n # rest of line
255             | # or
256             \s* \n # blank lines
257             )+ # end of indented block
258             ) # full bullet with leading bullet
259             }{
260 3         7 my $text = $1;
261 3         8 $text =~ s{ [*] }{ }xms;
262 3         14 "\\bullet[\n\n" . $text . "\n]\n";
263             }xmsge;
264              
265             # Do the same thing, but with numbered lists. This doesn't handle
266             # numbers larger than 9 currently, since that requires massaging the
267             # spacing.
268 39         200 $text =~ s{
269             ( # capture whole contents
270             ^ (\s*) # indent before number
271             \d [.] (\s+) # number and following indent
272             [^\n]+ \n # rest of line
273             (?: \s* \n )* # optional blank lines
274             (\2 [ ][ ] \3) # matching indent
275             [^\n]+ \n # rest of line
276             (?: # one or more of
277             \4 # matching indent
278             [^\n]+ \n # rest of line
279             | # or
280             \s* \n # blank lines
281             )+ # end of indented block
282             ) # full bullet with leading bullet
283             }{
284 3         8 my $text = $1;
285 3         13 $text =~ s{ \A (\s*) \d [.] }{$1 }xms;
286 3         18 "\\number[\n\n" . $text . "\n]\n\n";
287             }xmsge;
288              
289             # Rewrite compact bulleted lists.
290 39         111 $text =~ s{ \n ( (?: \s* [*] \s+ [^\n]+ \s* \n ){2,} ) }{
291 5         16 my $list = $1;
292 5         59 $list =~ s{ \n [*] \s+ ([^\n]+) }{\n\\bullet(packed)[$1]}xmsg;
293 5         25 "\n" . $list;
294             }xmsge;
295              
296             # Done. Return the results.
297 39         170 return $text;
298 34         153 };
299 34         76 return $to_thread;
300             }
301              
302             ##############################################################################
303             # Helper methods
304             ##############################################################################
305              
306             # Word-wrap a paragraph of text. This is a helper function for _wrap, mostly
307             # so that it can be invoked recursively to wrap bulleted paragraphs.
308             #
309             # If the paragraph looks like regular text, which means indented by two or
310             # four spaces and consistently on each line, remove the indentation and then
311             # add it back in while wrapping the text.
312             #
313             # $self - The App::DocKnot::Generate object
314             # $paragraph - A paragraph of text to wrap
315             #
316             # Returns: The wrapped paragraph
317             sub _wrap_paragraph {
318 2165     2165   3161 my ($self, $paragraph) = @_;
319 2165         5281 my ($indent) = ($paragraph =~ m{ \A ([ ]*) \S }xms);
320              
321             # If the indent is longer than four characters, leave it alone.
322 2165 100       4118 if (length($indent) > 4) {
323 167         353 return $paragraph;
324             }
325              
326             # If this looks like thread commands or URLs, leave it alone.
327 1998 100       3990 if ($paragraph =~ m{ \A \s* (?: \\ | \[\d+\] ) }xms) {
328 219         460 return $paragraph;
329             }
330              
331             # If this starts with a bullet, strip the bullet off, wrap the paragaraph,
332             # and then add it back in.
333 1779 100       3752 if ($paragraph =~ s{ \A (\s*) [*] (\s+) }{$1 $2}xms) {
334 80         151 my $offset = length($1);
335 80         179 $paragraph = $self->_wrap_paragraph($paragraph);
336 80         196 substr($paragraph, $offset, 1, q{*});
337 80         207 return $paragraph;
338             }
339              
340             # If this looks like a Markdown block quote leave it alone, but strip
341             # trailing whitespace.
342 1699 100       2767 if ($paragraph =~ m{ \A \s* > \s }xms) {
343 13         195 $paragraph =~ s{ [ ]+ \n }{\n}xmsg;
344 13         42 return $paragraph;
345             }
346              
347             # If this looks like a bunch of short lines, leave it alone.
348 1686 100       2738 if ($paragraph =~ m{ \A [^\n]{1,40} \n [^\n]{1,40} \n }xms) {
349 85         199 return $paragraph;
350             }
351              
352             # If this paragraph is not consistently indented, leave it alone.
353 1601 100       11474 if ($paragraph !~ m{ \A (?: \Q$indent\E \S[^\n]+ \n )+ \z }xms) {
354 105         311 return $paragraph;
355             }
356              
357             # Strip the indent from each line.
358 1496         29376 $paragraph =~ s{ (?: \A | (?<=\n) ) \Q$indent\E }{}xmsg;
359              
360             # Remove any existing newlines, preserving two spaces after periods.
361 1496         3546 $paragraph =~ s{ [.] ([)\"]?) \n (\S) }{.$1 $2}xmsg;
362 1496         6956 $paragraph =~ s{ \n(\S) }{ $1}xmsg;
363              
364             # Force locally correct configuration of Text::Wrap.
365 1496         3635 local $Text::Wrap::break = qr{\s+}xms;
366 1496         2632 local $Text::Wrap::columns = $self->{width} + 1;
367 1496         1893 local $Text::Wrap::huge = 'overflow';
368 1496         1745 local $Text::Wrap::unexpand = 0;
369              
370             # Do the wrapping. This modifies @paragraphs in place.
371 1496         2726 $paragraph = wrap($indent, $indent, $paragraph);
372              
373             # Strip any trailing whitespace, since some gets left behind after periods
374             # by Text::Wrap.
375 1496         178404 $paragraph =~ s{ [ ]+ \n }{\n}xmsg;
376              
377             # All done.
378 1496         4268 return $paragraph;
379             }
380              
381             # Word-wrap a block of text. This requires some annoying heuristics, but the
382             # alternative is to try to get the template to always produce correctly
383             # wrapped results, which is far harder.
384             #
385             # $self - The App::DocKnot::Generate object
386             # $text - The text to wrap
387             #
388             # Returns: The wrapped text
389             sub _wrap {
390 34     34   153 my ($self, $text) = @_;
391              
392             # First, break the text up into paragraphs. (This will also turn more
393             # than two consecutive newlines into just two newlines.)
394 34         2568 my @paragraphs = split(m{ \n(?:[ ]*\n)+ }xms, $text);
395              
396             # Add back the trailing newlines at the end of each paragraph.
397 34         111 @paragraphs = map { $_ . "\n" } @paragraphs;
  2085         3136  
398              
399             # Wrap all of the paragraphs. This modifies @paragraphs in place.
400 34         131 for my $paragraph (@paragraphs) {
401 2085         3476 $paragraph = $self->_wrap_paragraph($paragraph);
402             }
403              
404             # Glue the paragraphs back together and return the result. Because the
405             # last newline won't get stripped by the split above, we have to strip an
406             # extra newline from the end of the file.
407 34         613 my $result = join("\n", @paragraphs);
408 34         2044 $result =~ s{ \n+ \z }{\n}xms;
409 34         839 return $result;
410             }
411              
412             ##############################################################################
413             # Public interface
414             ##############################################################################
415              
416             # Create a new App::DocKnot::Generate object, which will be used for
417             # subsequent calls.
418             #
419             # $class - Class of object to create
420             # $args - Anonymous hash of arguments with the following keys:
421             # metadata - Path to the directory containing package metadata
422             # width - Line length at which to wrap output files
423             #
424             # Returns: Newly created object
425             # Throws: Text exceptions on invalid metadata directory path
426             sub new {
427 14     14 1 12303 my ($class, $args_ref) = @_;
428              
429             # Create the config reader.
430 14         29 my %config_args;
431 14 100       60 if ($args_ref->{metadata}) {
432 11         35 $config_args{metadata} = $args_ref->{metadata};
433             }
434 14         130 my $config = App::DocKnot::Config->new(\%config_args);
435              
436             # Create and return the object.
437             my $self = {
438             config => $config,
439 13   50     93 width => $args_ref->{width} // 74,
440             };
441 13         29 bless($self, $class);
442 13         42 return $self;
443             }
444              
445             # Generate a documentation file from the package metadata.
446             #
447             # $self - The App::DocKnot::Generate object
448             # $template - Name of the documentation template (using Template Toolkit)
449             #
450             # Returns: The generated documentation as a string
451             # Throws: autodie exception on failure to read metadata or write the output
452             # Text exception on Template Toolkit failures
453             # Text exception on inconsistencies in the package data
454             sub generate {
455 34     34 1 28419 my ($self, $template) = @_;
456              
457             # Load the package metadata.
458 34         259 my $data_ref = $self->{config}->config();
459              
460             # Create the variable information for the template. Start with all
461             # metadata as loaded above.
462 34         91 my %vars = %{$data_ref};
  34         302  
463              
464             # Add code references for our defined helper functions.
465 34         186 $vars{center} = $self->_code_for_center;
466 34         130 $vars{copyright} = $self->_code_for_copyright($data_ref->{copyrights});
467 34         115 $vars{indent} = $self->_code_for_indent;
468 34         115 $vars{to_text} = $self->_code_for_to_text;
469 34         108 $vars{to_thread} = $self->_code_for_to_thread;
470              
471             # Ensure we were given a valid template.
472 34         179 $template = $self->appdata_path('templates', "${template}.tmpl");
473              
474             # Run Template Toolkit processing.
475 34 50       366 my $tt = Template->new({ ABSOLUTE => 1 }) or croak(Template->error());
476 34         93842 my $result;
477 34 50       215 $tt->process($template, \%vars, \$result) or croak($tt->error);
478              
479             # Word-wrap the results to our width and return them.
480 34         4123 return $self->_wrap($result);
481             }
482              
483             # Generate all package documentation from the package metadata. Only
484             # generates the output for templates with a default output file.
485             #
486             # $self - The App::DocKnot::Generate object
487             #
488             # Returns: undef
489             # Throws: autodie exception on failure to read metadata or write the output
490             # Text exception on Template Toolkit failures
491             # Text exception on inconsistencies in the package data
492             sub generate_all {
493 2     2 1 1490 my ($self) = @_;
494 2         9 for my $template (keys(%DEFAULT_OUTPUT)) {
495 4         17 $self->generate_output($template);
496             }
497 2         18 return;
498             }
499              
500             # Generate a documentation file from the package metadata.
501             #
502             # $self - The App::DocKnot::Generate object
503             # $template - Name of the documentation template
504             # $output - Output file name (undef to use the default)
505             #
506             # Returns: undef
507             # Throws: autodie exception on failure to read metadata or write the output
508             # Text exception on Template Toolkit failures
509             # Text exception on inconsistencies in the package data
510             sub generate_output {
511 10     10 1 5047 my ($self, $template, $output) = @_;
512 10   66     57 $output //= $DEFAULT_OUTPUT{$template};
513              
514             # If the template doesn't have a default output file, $output is required.
515 10 50       27 if (!defined($output)) {
516 0         0 croak('missing required output argument');
517             }
518              
519             # Generate the output.
520 10         52 open(my $outfh, '>', $output);
521 10 50       12947 print {$outfh} $self->generate($template)
  10         122  
522             or croak("cannot write to $output: $!");
523 10         3499 close($outfh);
524 10         5382 return;
525             }
526              
527             ##############################################################################
528             # Module return value and documentation
529             ##############################################################################
530              
531             1;
532             __END__