File Coverage

blib/lib/Web/PerlDistSite.pm
Criterion Covered Total %
statement 15 130 11.5
branch 0 20 0.0
condition 0 6 0.0
subroutine 5 15 33.3
pod 0 9 0.0
total 20 180 11.1


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 C<meta> key allows you to provide metadata for a page. It's an array of
199: hashrefs. Each item in the array will result in a C<< <meta content=""> >>
200: or C<< <link href=""> >> tag added to the document's C<< <head> >>. For
201: example:
202:
203: - name: installation
204: title: How to install
205: source: _pages/installation.html
206: meta:
207: - name: description
208: content: "How to install my module."
209: - rel: related
210: href: "https://videotube.example/1234"
211: title: "Watch a screen recording of module installation"
212:
213: A list item like this can be used to add dividers:
214:
215: - divider: true
216:
217: If the input is pod, you can also provide C<pod_filter>, C<pod_titlecase>,
218: and C<pod_downgrade_headings> settings which override the global settings.
219:
220: If the input is markdown, you may use "----" (four hyphens) on a line by
221: itself to divide the page into cute sections.
222:
223: =head2 Homepage
224:
225: You'll need a file called F<< _pages/index.md >> for the site's homepage.
226: The filename may be configurable some day.
227:
228: =head2 Custom CSS
229:
230: You can create a file called F<< custom.scss >> containing custom SCSS
231: code to override or add to the defaults.
232:
233: =head2 Adding Images
234:
235: If you create a directory called F<images>, this will be copied to
236: F<docs/assets/images/> during the build process. This should be used
237: for things like background images, etc.
238:
239: =head2 Building the Site
240:
241: Running C<< make all >> will build the site.
242:
243: Running C<< make clean >> will remove the C<docs> directory and
244: also any temporary SCSS files created during the build process.
245:
246: =head1 EXAMPLE
247:
248: Example: L<https://github.com/exportertiny/exportertiny.github.io>
249:
250: Generated this site: L<https://exportertiny.github.io>
251:
252: =head2 More Examples
253:
254: =over
255:
256: =item *
257:
258: L<https://typetiny.toby.ink/>
259:
260: =item *
261:
262: L<https://story-interact.xlc.pl/>
263:
264: =item *
265:
266: L<https://ology.github.io/midi-drummer-tiny-tutorial/>
267:
268: =item *
269:
270: L<https://ology.github.io/music-duration-partition-tutorial/>
271:
272: =back
273:
274: =head1 BUGS
275:
276: Please report any bugs to
277: L<https://github.com/tobyink/p5-web-perldistsite/issues>.
278:
279: =head1 AUTHOR
280:
281: Toby Inkster E<lt>tobyink@cpan.orgE<gt>.
282:
283: =head1 COPYRIGHT AND LICENCE
284:
285: This software is copyright (c) 2023 by Toby Inkster.
286:
287: This is free software; you can redistribute it and/or modify it under
288: the same terms as the Perl 5 programming language system itself.
289:
290: =head1 DISCLAIMER OF WARRANTIES
291:
292: THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
293: WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
294: MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE.
295:
296: =cut
297:
298: use Moo;
299: use Web::PerlDistSite::Common -lexical, -all;
300:
301: our $VERSION = '0.001011';
302:
303: use Web::PerlDistSite::MenuItem ();
304: use HTML::HTML5::Parser ();
305:
306: has root => (
307: is => 'ro',
308: isa => PathTiny,
309: required => true,
310: coerce => true,
311: );
312:
313: has root_url => (
314: is => 'rwp',
315: isa => Str,
316: default => '/',
317: );
318:
319: has dist_dir => (
320: is => 'lazy',
321: isa => PathTiny,
322: coerce => true,
323: builder => sub ( $s ) { $s->root->child( 'docs' ) },
324: );
325:
326: has name => (
327: is => 'ro',
328: isa => Str,
329: required => true,
330: );
331:
332: has abstract => (
333: is => 'ro',
334: isa => Str,
335: required => true,
336: );
337:
338: has abstract_html => (
339: is => 'lazy',
340: isa => Str,
341: default => sub ( $s ) { esc_html( $s->abstract ) },
342: );
343:
344: has issues => (
345: is => 'ro',
346: isa => Str,
347: );
348:
349: has copyright => (
350: is => 'ro',
351: isa => Str,
352: );
353:
354: has github => (
355: is => 'ro',
356: isa => Str,
357: );
358:
359: has sponsor => (
360: is => 'ro',
361: isa => HashRef,
362: );
363:
364: has theme => (
365: is => 'ro',
366: isa => HashRef->of( Str ),
367: );
368:
369: has codestyle => (
370: is => 'ro',
371: isa => Str,
372: default => 'github',
373: );
374:
375: has pod_filter => (
376: is => 'ro',
377: isa => Str,
378: default => 'NAME|BUGS|AUTHOR|THANKS|COPYRIGHT AND LICENCE|DISCLAIMER OF WARRANTIES',
379: );
380:
381: has pod_titlecase => (
382: is => 'ro',
383: isa => Bool,
384: default => true,
385: );
386:
387: has pod_downgrade_headings => (
388: is => 'ro',
389: isa => Bool,
390: default => true,
391: );
392:
393: has menu => (
394: is => 'ro',
395: isa => ArrayRef->of(
396: InstanceOf
397: ->of( 'Web::PerlDistSite::MenuItem' )
398: ->plus_constructors( HashRef, 'from_hashref' )
399: ),
400: coerce => true,
401: );
402:
403: has homepage => (
404: is => 'ro',
405: isa => HashRef,
406: default => sub ( $s ) {
407: return { animation => 'waves1' };
408: },
409: );
410:
411: sub css_timestamp ( $self ) {
412: $self->dist_dir->child( 'assets/styles/main.css' )->stat->mtime;
413: }
414:
415: sub load ( $class, $filename='config.yaml' ) {
416: my $data = YAML::PP::LoadFile( $filename );
417: $data->{root} //= path( $filename )->absolute->parent;
418: $data->{menu} //= YAML::PP::LoadFile( $data->{root}->child( 'menu.yaml' ) );
419: $class->new( $data->%* );
420: }
421:
422: sub footer ( $self ) {
423: my @sections;
424:
425: if ( $self->github ) {
426: push @sections, sprintf(
427: '<h2>Contributing</h2>
428: <p>%s is an open source project <a href="%s">hosted on GitHub</a> —
429: open an issue if you have an idea or find a bug.</p>',
430: esc_html( $self->name ),
431: esc_html( $self->github ),
432: );
433: if ( $self->github =~ m{^https://github.com/(.+)$} ) {
434: my $path = $1;
435: $sections[-1] .= sprintf(
436: '<p><img alt="GitHub repo stars"
437: src="https://img.shields.io/github/stars/%s?style=social"></p>',
438: $path,
439: );
440: }
441: }
442:
443: if ( $self->sponsor ) {
444: push @sections, sprintf(
445: '<h2>Sponsoring</h2>
446: <p>%s</p>',
447: esc_html( $self->sponsor->{html} ),
448: );
449: if ( $self->sponsor->{href} ) {
450: $sections[-1] .= sprintf(
451: '<p><a class="btn btn-light" href="%s"><span class="text-dark">Sponsor</span></a></p>',
452: esc_html( $self->sponsor->{href} ),
453: );
454: }
455: }
456:
457: my $width = int( 12 / @sections );
458: my @html;
459: push @html, '<div class="container">';
460: push @html, '<div class="row">';
461: for my $section ( @sections ) {
462: push @html, "<div class=\"col-12 col-lg-$width\">$section</div>";
463: }
464: if ( $self->copyright ) {
465: push @html, '<div class="col-12 text-center pt-3">';
466: push @html, sprintf( '<p>%s</p>', esc_html( $self->copyright ) );
467: push @html, '</div>';
468: }
469: push @html, '</div>';
470: push @html, '</div>';
471: return join qq{\n}, @html;
472: }
473:
474: sub navbar ( $self, $active_item ) {
475: my @html;
476: push @html, '<nav class="navbar navbar-expand-lg">';
477: push @html, '<div class="container">';
478: push @html, sprintf( '<a class="navbar-brand" href="%s">%s</a>', $self->root_url, $self->name );
479: 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">';
480: push @html, '<span class="navbar-toggler-icon"></span>';
481: push @html, '</button>';
482: push @html, '<div class="collapse navbar-collapse" id="navbarSupportedContent">';
483: push @html, '<ul class="navbar-nav ms-auto mb-2 mb-lg-0">';
484: push @html, map $_->nav_item( $active_item ), $self->menu->@*;
485: push @html, '</ul>';
486: push @html, '</div>';
487: push @html, '</div>';
488: push @html, '</nav>';
489: return join qq{\n}, @html;
490: }
491:
492: sub BUILD ( $self, $args ) {
493: $_->project( $self ) for $self->menu->@*;
494: $self->root_url( $self->root_url . '/' ) unless $self->root_url =~ m{/$};
495: }
496:
497: sub write_pages ( $self ) {
498: for my $item ( $self->menu->@* ) {
499: $item->write_pages;
500: }
501: $self->write_homepage;
502: }
503:
504: sub write_variables_scss ( $self ) {
505: my $scss = '';
506: for my $key ( sort keys $self->theme->%* ) {
507: $scss .= sprintf( '$%s: %s;', $key, $self->theme->{$key} ) . "\n";
508: }
509: $self->root->child( '_build/variables.scss' )->spew_if_changed( $scss );
510: }
511:
512: sub write_homepage ( $self ) {
513: require Web::PerlDistSite::MenuItem::Homepage;
514: my $page = Web::PerlDistSite::MenuItem::Homepage->new(
515: $self->homepage->%*,
516: project => $self,
517: );
518: $page->write_pages;
519: }
520:
521: sub get_template_page ( $self, $item=undef ) {
522: state $template = do {
523: local $/;
524: my $html = <DATA>;
525: $html =~ s[\{\{\s*\$root\s*\}\}]{$self->root_url}ge;
526: $html =~ s[\{\{\s*\$codestyle\s*\}\}]{$self->codestyle}ge;
527: $html =~ s[\{\{\s*\$css_timestamp\s*\}\}]{$self->css_timestamp}ge;
528: $html;
529: };
530:
531: state $p = HTML::HTML5::Parser->new;
532: my $dom = $p->parse_string( $template );
533:
534: my $navbar = $p->parse_balanced_chunk( $self->navbar( $item ) );
535: $dom->getElementsByTagName( 'header' )->shift->appendChild( $navbar );
536:
537: my $footer = $p->parse_balanced_chunk( $self->footer );
538: $dom->getElementsByTagName( 'footer' )->shift->appendChild( $footer );
539:
540: $dom->getElementsByTagName( 'title' )->shift->appendText( $item ? $item->page_title : $self->name );
541:
542: if ( $item ) {
543: my $head = $dom->getElementsByTagName( 'head' )->shift;
544: if ( my $meta = $item->meta ) {
545: for my $m ( @$meta ) {
546: my $tagname = exists( $m->{href} ) ? 'link' : 'meta';
547: %{ $head->addNewChild( $head->namespaceURI, $tagname ) } = %$m;
548: }
549: }
550: }
551:
552: return $dom;
553: }
554:
555: 1;
556:
557: __DATA__
558: <html prefix="rdfs: http://www.w3.org/2000/01/rdf-schema# dc: http://purl.org/dc/terms/ foaf: http://xmlns.com/foaf/0.1/ s: https://schema.org/ og: https://ogp.me/ns#">
559: <head>
560: <meta charset="utf-8">
561: <meta name="viewport" content="width=device-width, initial-scale=1">
562: <title></title>
563: <link href="{{ $root }}assets/styles/main.css?v={{ $css_timestamp }}" rel="stylesheet">
564: <link rel="stylesheet" href="//unpkg.com/@highlightjs/cdn-assets@11.7.0/styles/{{ $codestyle }}.min.css">
565: </head>
566: <body id="top">
567: <header></header>
568: <main></main>
569: <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>
570: <footer id="bottom"></footer>
571: <a id="return-to-top" href="#top"><i class="fa-solid fa-circle-up"></i></a>
572: <script src="{{ $root }}assets/scripts/bootstrap.bundle.min.js"></script>
573: <script src="//kit.fontawesome.com/6d700b1a29.js" crossorigin="anonymous"></script>
574: <script src="//unpkg.com/@highlightjs/cdn-assets@11.7.0/highlight.min.js"></script>
575: <script>
576: const classy_scroll = function () {
577: const scroll = document.documentElement.scrollTop;
578: const avail = window.screen.availHeight;
579: if ( scroll > 75 ) {
580: document.body.classList.add( 'is-scrolled' );
581: document.body.classList.remove( 'at-top' );
582: }
583: else if ( scroll < 25 ) {
584: document.body.classList.remove( 'is-scrolled' );
585: document.body.classList.add( 'at-top' );
586: }
587: if ( scroll > avail ) {
588: document.body.classList.add( 'is-scrolled-deeply' );
589: }
590: else if ( scroll < avail ) {
591: document.body.classList.remove( 'is-scrolled-deeply' );
592: }
593: };
594: classy_scroll();
595: window.addEventListener( 'scroll', classy_scroll );
596:
597: hljs.highlightAll();
598: </script>
599: </body>
600: </html>
601: