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