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