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