File Coverage

blib/lib/Web/PerlDistSite.pm
Criterion Covered Total %
statement 15 123 12.2
branch 0 14 0.0
condition 0 6 0.0
subroutine 5 15 33.3
pod 0 9 0.0
total 20 167 11.9


line stmt bran cond sub pod time code
1             package Web::PerlDistSite;
2:
use utf8;
3:
4: =pod 5:
6: =encoding utf-8
7:
8: =head1 NAME
9:
10: Web::PerlDistSite - generate fairly flashy websites for CPAN distributions
11:
12: =head1 DESCRIPTION
13:
14: Basically a highly specialized static site generator.
15:
16: =head2 Prerequisites
17:
18: You will need B<cpanm>.
19:
20: You will need B<nodejs> with B<npm>.
21:
22: You will need B<make>.
23:
24: =head2 Setup
25:
26: Create a directory and copy the example F<Makefile> and F<package.json>
27: files from this distribution into it. Then run C<< make install >> to
28: install additional Nodejs and Perl dependencies.
29:
30: =head2 Site Configuration
31:
32: Configuration is via a file F<config.yaml>. This is a YAML file containing
33: a hash with the following keys. Each key is optional, unless noted as required.
34: An example F<config.yaml> is included in this distribution.
35:
36: =over
37:
38: =item C<< theme >> I<< (required) >>
39:
40: A hashref of colour codes. You need at least "primary", "secondary", "light",
41: and "dark". "info", "success", "warning", and "danger" are also allowed.
42:
43: theme:
44: light: "#e4e3e1"
45: dark: "#32201D"
46: primary: "#763722"
47: secondary: "#E4A042"
48:
49: A good colour generator can be found at L<https://huemint.com/bootstrap-basic/>
50: if you're stuck for ideas.
51:
52: The C<theme> hashref can also include Bootstrap's non-colour SASS options.
53: See L<https://getbootstrap.com/docs/5.2/customize/options/>.
54:
55: An example is:
56:
57: theme:
58: light: "#e4e3e1"
59: dark: "#32201D"
60: primary: "#763722"
61: secondary: "#E4A042"
62: "enable-shadows": "true"
63:
64: =item C<< name >> I<< (required) >>
65:
66: The name of the project you're building a website for. This is assumed
67: to be a CPAN distribution name, like "Exporter-Tiny" or "Foo-Bar-Baz".
68:
69: =item C<< abstract >> I<< (required) >>
70:
71: A short plain-text summary of the project.
72:
73: =item C<< abstract_html >>
74:
75: A short HTML summary of the project.
76:
77: =item C<< copyright >> I<< (required) >>
78:
79: A short plain-text copyright statement for the website footers.
80:
81: =item C<< github >>
82:
83: Link to a GitHub repo for the site. Expected to be of the form
84: "https://github.com/username/reponame".
85:
86: =item C<< issues >>
87:
88: Link to an issue tracker.
89:
90: =item C<< sponsor >>
91:
92: Hashref containing project sponsorship info. The "html" key is required.
93: The "href" key is optional.
94:
95: sponsor:
96: html: "<strong>Please sponsor us!</strong> Blah blah blah."
97: href: https://paypal.example/foo-bar-baz
98:
99: =item C<< menu >>
100:
101: A list of files to include in the navbar. If this key is missing, will
102: be loaded from F<menu.yaml> instead.
103:
104: =item C<< homepage >>
105:
106: Hashref of options for the homepage (index.html). May contain keys
107: "animation", "banner", "banner_fixation", "banner_position_x", and
108: "banner_position_y".
109:
110: The "animation" may be "waves1", "waves2", "swirl1", "attract1", or "circles1".
111: Each of these will create a pretty animation on the homepage. Some
112: day I'll add support for more animations.
113:
114: If "animation" is not defined, then "banner" can be used to supply
115: the URL of a static image to use instead of an animation.
116:
117: "banner_fixation" can be "scroll" or "fixed", and defaults to the latter.
118: "banner_position_x" can be "left", "center", or "right". "banner_position_y"
119: can be "top", "center", or "bottom". These each default to "center".
120:
121: "hero_options" is I<itself> a hashref and allows various parts of the
122: banner/animation to be overridden. In particular, "title" and "abstract".
123:
124: homepage:
125: animation: waves1
126: hero_options:
127: title: "Blah"
128: abstract: "Blah blah blah"
129:
130: In the future, more homepage options may be available.
131:
132: =item C<< dist_dir >>
133:
134: Directory for output. Defaults to a subdirectory called "docs".
135:
136: =item C<< root_url >>
137:
138: URL for the output. Can be an absolute URL, but something like "/"
139: is probably okay. (That's the default.)
140:
141: =item C<< codestyle >>
142:
143: Name of a highlight.js theme, used for code syntax highlighting.
144: Defaults to "github".
145:
146: =item C<< pod_filter >>
147:
148: A list of section names which will be filtered out of pages generated
149: from pod files. Uses "|" as a separator. Defaults to:
150: "NAME|BUGS|AUTHOR|THANKS|COPYRIGHT AND LICENCE|DISCLAIMER OF WARRANTIES".
151:
152: =item C<< pod_titlecase >>
153:
154: Boolean. Should ALL CAPS "=head1" headings from pod be converted to
155: Title Case? Defaults to true.
156:
157: =item C<< pod_downgrade_headings >>
158:
159: Converts pod "=head1" to C<< <h2> >> tags in HTML, etc.
160:
161: =back
162:
163: =head2 Menu Configuration
164:
165: The menu can be configured under the C<menu> key of F<config.yaml>, or in
166: a separate file F<menu.yaml>. This is a list of menu items. For example:
167:
168: - name: installation
169: title: Installation
170: source: _pages/installation.md
171: - name: hints
172: title: Hints and Tips
173: source: _pages/hints.pod
174: - name: manual
175: title: Manual
176: children:
177: - name: Foo-Bar
178: pod: Foo::Bar
179: - name: Foo-Bar-Baz
180: pod: Foo::Bar::Baz
181:
182: The C<name> key is used for the output filename. ".html" is automatically
183: appended.
184:
185: The C<title> key is the title of the document generated. It is used in the
186: navbar and in the page's C<< C<title> >> element.
187:
188: Each entry needs a C<source> which is an input filename. The input may
189: be pod or markdown. (At some future point, HTML input will also be supported.)
190:
191: If the C<pod> key is found, we'll find the pod via C<< @INC >>, like
192: perldoc does. This will helpfully also default C<title> and C<name> for you!
193:
194: A C<children> key allows child pages to be listed. Only one level of nesting
195: is supported in the navbar, but if further levels of nesting are used, these
196: pages will still be built. (You'll just need to link to them manually somehow.)
197:
198: A list item like this can be used to add dividers:
199:
200: - divider: true
201:
202: If the input is pod, you can also provide C<pod_filter>, C<pod_titlecase>,
203: and C<pod_downgrade_headings> settings which override the global settings.
204:
205: If the input is markdown, you may use "----" (four hyphens) on a line by
206: itself to divide the page into cute sections.
207:
208: =head2 Homepage
209:
210: You'll need a file called F<< _pages/index.md >> for the site's homepage.
211: The filename may be configurable some day.
212:
213: =head2 Custom CSS
214:
215: You can create a file called F<< custom.scss >> containing custom SCSS
216: code to override or add to the defaults.
217:
218: =head2 Adding Images
219:
220: If you create a directory called F<images>, this will be copied to
221: F<docs/assets/images/> during the build process. This should be used
222: for things like background images, etc.
223:
224: =head2 Building the Site
225:
226: Running C<< make all >> will build the site.
227:
228: Running C<< make clean >> will remove the C<docs> directory and
229: also any temporary SCSS files created during the build process.
230:
231: =head1 EXAMPLE
232:
233: Example: L<https://github.com/exportertiny/exportertiny.github.io>
234:
235: Generated this site: L<https://exportertiny.github.io>
236:
237: =head2 More Examples
238:
239: =over
240:
241: =item *
242:
243: L<https://typetiny.toby.ink/>
244:
245: =item *
246:
247: L<https://story-interact.xlc.pl/>
248:
249: =item *
250:
251: L<https://ology.github.io/midi-drummer-tiny-tutorial/>
252:
253: =item *
254:
255: L<https://ology.github.io/music-duration-partition-tutorial/>
256:
257: =back
258:
259: =head1 BUGS
260:
261: Please report any bugs to
262: L<https://github.com/tobyink/p5-web-perldistsite/issues>.
263:
264: =head1 AUTHOR
265:
266: Toby Inkster E<lt>tobyink@cpan.orgE<gt>.
267:
268: =head1 COPYRIGHT AND LICENCE
269:
270: This software is copyright (c) 2023 by Toby Inkster.
271:
272: This is free software; you can redistribute it and/or modify it under
273: the same terms as the Perl 5 programming language system itself.
274:
275: =head1 DISCLAIMER OF WARRANTIES
276:
277: THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
278: WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
279: MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE.
280:
281: =cut
282:
283: use Moo;
284: use Web::PerlDistSite::Common -lexical, -all;
285:
286: our $VERSION = '0.001010';
287:
288: use Web::PerlDistSite::MenuItem ();
289: use HTML::HTML5::Parser ();
290:
291: has root => (
292: is => 'ro',
293: isa => PathTiny,
294: required => true,
295: coerce => true,
296: );
297:
298: has root_url => (
299: is => 'rwp',
300: isa => Str,
301: default => '/',
302: );
303:
304: has dist_dir => (
305: is => 'lazy',
306: isa => PathTiny,
307: coerce => true,
308: builder => sub ( $s ) { $s->root->child( 'docs' ) },
309: );
310:
311: has name => (
312: is => 'ro',
313: isa => Str,
314: required => true,
315: );
316:
317: has abstract => (
318: is => 'ro',
319: isa => Str,
320: required => true,
321: );
322:
323: has abstract_html => (
324: is => 'lazy',
325: isa => Str,
326: default => sub ( $s ) { esc_html( $s->abstract ) },
327: );
328:
329: has issues => (
330: is => 'ro',
331: isa => Str,
332: );
333:
334: has copyright => (
335: is => 'ro',
336: isa => Str,
337: );
338:
339: has github => (
340: is => 'ro',
341: isa => Str,
342: );
343:
344: has sponsor => (
345: is => 'ro',
346: isa => HashRef,
347: );
348:
349: has theme => (
350: is => 'ro',
351: isa => HashRef->of( Str ),
352: );
353:
354: has codestyle => (
355: is => 'ro',
356: isa => Str,
357: default => 'github',
358: );
359:
360: has pod_filter => (
361: is => 'ro',
362: isa => Str,
363: default => 'NAME|BUGS|AUTHOR|THANKS|COPYRIGHT AND LICENCE|DISCLAIMER OF WARRANTIES',
364: );
365:
366: has pod_titlecase => (
367: is => 'ro',
368: isa => Bool,
369: default => true,
370: );
371:
372: has pod_downgrade_headings => (
373: is => 'ro',
374: isa => Bool,
375: default => true,
376: );
377:
378: has menu => (
379: is => 'ro',
380: isa => ArrayRef->of(
381: InstanceOf
382: ->of( 'Web::PerlDistSite::MenuItem' )
383: ->plus_constructors( HashRef, 'from_hashref' )
384: ),
385: coerce => true,
386: );
387:
388: has homepage => (
389: is => 'ro',
390: isa => HashRef,
391: default => sub ( $s ) {
392: return { animation => 'waves1' };
393: },
394: );
395:
396: sub css_timestamp ( $self ) {
397: $self->dist_dir->child( 'assets/styles/main.css' )->stat->mtime;
398: }
399:
400: sub load ( $class, $filename='config.yaml' ) {
401: my $data = YAML::PP::LoadFile( $filename );
402: $data->{root} //= path( $filename )->absolute->parent;
403: $data->{menu} //= YAML::PP::LoadFile( $data->{root}->child( 'menu.yaml' ) );
404: $class->new( $data->%* );
405: }
406:
407: sub footer ( $self ) {
408: my @sections;
409:
410: if ( $self->github ) {
411: push @sections, sprintf(
412: '<h2>Contributing</h2>
413: <p>%s is an open source project <a href="%s">hosted on GitHub</a> —
414: open an issue if you have an idea or find a bug.</p>',
415: esc_html( $self->name ),
416: esc_html( $self->github ),
417: );
418: if ( $self->github =~ m{^https://github.com/(.+)$} ) {
419: my $path = $1;
420: $sections[-1] .= sprintf(
421: '<p><img alt="GitHub repo stars"
422: src="https://img.shields.io/github/stars/%s?style=social"></p>',
423: $path,
424: );
425: }
426: }
427:
428: if ( $self->sponsor ) {
429: push @sections, sprintf(
430: '<h2>Sponsoring</h2>
431: <p>%s</p>',
432: esc_html( $self->sponsor->{html} ),
433: );
434: if ( $self->sponsor->{href} ) {
435: $sections[-1] .= sprintf(
436: '<p><a class="btn btn-light" href="%s"><span class="text-dark">Sponsor</span></a></p>',
437: esc_html( $self->sponsor->{href} ),
438: );
439: }
440: }
441:
442: my $width = int( 12 / @sections );
443: my @html;
444: push @html, '<div class="container">';
445: push @html, '<div class="row">';
446: for my $section ( @sections ) {
447: push @html, "<div class=\"col-12 col-lg-$width\">$section</div>";
448: }
449: if ( $self->copyright ) {
450: push @html, '<div class="col-12 text-center pt-3">';
451: push @html, sprintf( '<p>%s</p>', esc_html( $self->copyright ) );
452: push @html, '</div>';
453: }
454: push @html, '</div>';
455: push @html, '</div>';
456: return join qq{\n}, @html;
457: }
458:
459: sub navbar ( $self, $active_item ) {
460: my @html;
461: push @html, '<nav class="navbar navbar-expand-lg">';
462: push @html, '<div class="container">';
463: push @html, sprintf( '<a class="navbar-brand" href="%s">%s</a>', $self->root_url, $self->name );
464: push @html, '<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">';
465: push @html, '<span class="navbar-toggler-icon"></span>';
466: push @html, '</button>';
467: push @html, '<div class="collapse navbar-collapse" id="navbarSupportedContent">';
468: push @html, '<ul class="navbar-nav ms-auto mb-2 mb-lg-0">';
469: push @html, map $_->nav_item( $active_item ), $self->menu->@*;
470: push @html, '</ul>';
471: push @html, '</div>';
472: push @html, '</div>';
473: push @html, '</nav>';
474: return join qq{\n}, @html;
475: }
476:
477: sub BUILD ( $self, $args ) {
478: $_->project( $self ) for $self->menu->@*;
479: $self->root_url( $self->root_url . '/' ) unless $self->root_url =~ m{/$};
480: }
481:
482: sub write_pages ( $self ) {
483: for my $item ( $self->menu->@* ) {
484: $item->write_pages;
485: }
486: $self->write_homepage;
487: }
488:
489: sub write_variables_scss ( $self ) {
490: my $scss = '';
491: for my $key ( sort keys $self->theme->%* ) {
492: $scss .= sprintf( '$%s: %s;', $key, $self->theme->{$key} ) . "\n";
493: }
494: $self->root->child( '_build/variables.scss' )->spew_if_changed( $scss );
495: }
496:
497: sub write_homepage ( $self ) {
498: require Web::PerlDistSite::MenuItem::Homepage;
499: my $page = Web::PerlDistSite::MenuItem::Homepage->new(
500: $self->homepage->%*,
501: project => $self,
502: );
503: $page->write_pages;
504: }
505:
506: sub get_template_page ( $self, $item=undef ) {
507: state $template = do {
508: local $/;
509: my $html = <DATA>;
510: $html =~ s[\{\{\s*\$root\s*\}\}]{$self->root_url}ge;
511: $html =~ s[\{\{\s*\$codestyle\s*\}\}]{$self->codestyle}ge;
512: $html =~ s[\{\{\s*\$css_timestamp\s*\}\}]{$self->css_timestamp}ge;
513: $html;
514: };
515:
516: state $p = HTML::HTML5::Parser->new;
517: my $dom = $p->parse_string( $template );
518:
519: my $navbar = $p->parse_balanced_chunk( $self->navbar( $item ) );
520: $dom->getElementsByTagName( 'header' )->shift->appendChild( $navbar );
521:
522: my $footer = $p->parse_balanced_chunk( $self->footer );
523: $dom->getElementsByTagName( 'footer' )->shift->appendChild( $footer );
524:
525: $dom->getElementsByTagName( 'title' )->shift->appendText( $item ? $item->page_title : $self->name );
526:
527: return $dom;
528: }
529:
530: 1;
531:
532: __DATA__
533: <html>
534: <head>
535: <meta charset="utf-8">
536: <meta name="viewport" content="width=device-width, initial-scale=1">
537: <title></title>
538: <link href="{{ $root }}assets/styles/main.css?v={{ $css_timestamp }}" rel="stylesheet">
539: <link rel="stylesheet" href="//unpkg.com/@highlightjs/cdn-assets@11.7.0/styles/{{ $codestyle }}.min.css">
540: </head>
541: <body id="top">
542: <header></header>
543: <main></main>
544: <div id="footer-swish" style="height: 150px; overflow: hidden;"><svg viewBox="0 0 500 150" preserveAspectRatio="none" style="height: 100%; width: 100%;"><path d="M0.00,49.98 C138.82,121.67 349.20,-49.98 500.00,49.98 L500.00,150.00 L0.00,150.00 Z" style="stroke: none; fill: rgba(var(--bs-dark-rgb), 1);"></path></svg></div>
545: <footer id="bottom"></footer>
546: <a id="return-to-top" href="#top"><i class="fa-solid fa-circle-up"></i></a>
547: <script src="{{ $root }}assets/scripts/bootstrap.bundle.min.js"></script>
548: <script src="//kit.fontawesome.com/6d700b1a29.js" crossorigin="anonymous"></script>
549: <script src="//unpkg.com/@highlightjs/cdn-assets@11.7.0/highlight.min.js"></script>
550: <script>
551: const classy_scroll = function () {
552: const scroll = document.documentElement.scrollTop;
553: const avail = window.screen.availHeight;
554: if ( scroll > 75 ) {
555: document.body.classList.add( 'is-scrolled' );
556: document.body.classList.remove( 'at-top' );
557: }
558: else if ( scroll < 25 ) {
559: document.body.classList.remove( 'is-scrolled' );
560: document.body.classList.add( 'at-top' );
561: }
562: if ( scroll > avail ) {
563: document.body.classList.add( 'is-scrolled-deeply' );
564: }
565: else if ( scroll < avail ) {
566: document.body.classList.remove( 'is-scrolled-deeply' );
567: }
568: };
569: classy_scroll();
570: window.addEventListener( 'scroll', classy_scroll );
571:
572: hljs.highlightAll();
573: </script>
574: </body>
575: </html>
576: