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: |