diff options
author | Russ Allbery <rra@cpan.org> | 2022-01-15 17:13:04 -0800 |
---|---|---|
committer | Russ Allbery <rra@cpan.org> | 2022-01-15 17:13:04 -0800 |
commit | 93c237ea28bca715913206ad8281762fb8c4426f (patch) | |
tree | 0f01c01d58246deedb607112a092e12f8d0de564 | |
parent | 43c3505a30836ad5fd15d048e07143392464bfc1 (diff) | |
parent | ca30d64285e120b991f994808100ff866d7bdf7a (diff) |
Update upstream source from tag 'upstream/6.01'
Update to upstream version '6.01'
with Debian dir 878f458f477ea3beb96e10fac4dd203394554c41
49 files changed, 1389 insertions, 487 deletions
@@ -2,7 +2,7 @@ # # Build script for the docknot application. # -# Copyright 2013, 2016, 2018-2021 Russ Allbery <rra@cpan.org> +# Copyright 2013, 2016, 2018-2022 Russ Allbery <rra@cpan.org> # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -74,9 +74,11 @@ my $build = Module::Build->new( 'JSON::MaybeXS' => 0, 'Kwalify' => 0, 'List::SomeUtils' => '0.07', - 'Path::Tiny' => 0, + 'Path::Iterator::Rule' => 0, + 'Path::Tiny' => '0.101', 'Perl6::Slurp' => 0, - 'Pod::Thread' => '3.00', + 'Pod::Thread' => '3.01', + 'Sort::Versions' => 0, 'Template' => 0, 'YAML::XS' => '0.81', perl => '5.024', @@ -1,6 +1,31 @@ Revision history for DocKnot -6.00 - Not Released +6.01 - 2022-01-15 + + - Add new docknot release command and corresponding App::DocKnot::Release + module to copy a tarball releaes (normally created by docknot dist) + into a release area, update symlinks, archive any previous releases, + and update the .versions file used by docknot spin. DocKnot now + depends on Sort::Versions. + + - Add new docknot update-spin command and corresponding update_spin + method in App::DocKnot::Update to update a spin input tree to the + latest expectations. Currently, all this does is convert *.rpod + pointer files to *.spin pointer files. + + - docknot spin now uses Path::Iterator::Rule and Path::Tiny to construct + its paths, which eliminates the need to change the working directory + while processing input files. + + - Fix spurious requirement for a package metadata file when running + docknot spin. + + - Don't overwrite output files from docknot generate or generate-all + if the generation fails. + + - Require Pod::Thread 3.01 or later. + +6.00 - 2021-12-25 - Add a new *.spin input file for docknot spin that points to an external file with instructions for how to convert it to HTML. Via this @@ -9,8 +9,8 @@ Comment: This file documents the copyright statements and licenses for Files: * Copyright: 1993-1994, 1996-1998, 2000, 2002-2014 The Board of Trustees of the Leland Stanford Junior University - 2000-2004, 2006-2021 Russ Allbery <eagle@eyrie.org> - 1999-2011, 2013-2021 Russ Allbery <rra@cpan.org> + 2000-2004, 2006-2022 Russ Allbery <eagle@eyrie.org> + 1999-2011, 2013-2022 Russ Allbery <rra@cpan.org> License: Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -34,7 +34,7 @@ License: Expat Files: t/data/perltidyrc Copyright: 2012-2013 The Board of Trustees of the Leland Stanford Junior University - 2021 Russ Allbery <eagle@eyrie.org> + 2021-2022 Russ Allbery <eagle@eyrie.org> License: all-permissive Copying and distribution of this file, with or without modification, are permitted in any medium without royalty provided the copyright notice and @@ -11,6 +11,7 @@ lib/App/DocKnot/Command.pm lib/App/DocKnot/Config.pm lib/App/DocKnot/Dist.pm lib/App/DocKnot/Generate.pm +lib/App/DocKnot/Release.pm lib/App/DocKnot/Spin.pm lib/App/DocKnot/Spin/Pointer.pm lib/App/DocKnot/Spin/RSS.pm @@ -139,6 +140,12 @@ t/data/spin/sitemap/complex t/data/spin/sitemap/complex.html t/data/spin/sitemap/duplicate t/data/spin/sitemap/invalid +t/data/spin/update/input/module.rpod +t/data/spin/update/input/readme.rpod +t/data/spin/update/input/script.rpod +t/data/spin/update/output/module.spin +t/data/spin/update/output/readme.spin +t/data/spin/update/output/script.spin t/data/spin/versions/continuation t/data/spin/versions/invalid-continuation t/data/spin/versions/invalid-date @@ -235,6 +242,7 @@ t/lib/Test/RRA.pm t/lib/Test/RRA/Config.pm t/lib/Test/RRA/ModuleVersion.pm t/metadata/licenses.t +t/release/basic.t t/spin/errors.t t/spin/file.t t/spin/markdown.t @@ -249,6 +257,7 @@ t/style/module-version.t t/style/obsolete-strings.t t/style/strict.t t/update/basic.t +t/update/spin.t TODO META.yml META.json @@ -33,9 +33,11 @@ "JSON::MaybeXS" : "0", "Kwalify" : "0", "List::SomeUtils" : "0.07", - "Path::Tiny" : "0", + "Path::Iterator::Rule" : "0", + "Path::Tiny" : "0.101", "Perl6::Slurp" : "0", - "Pod::Thread" : "3.00", + "Pod::Thread" : "3.01", + "Sort::Versions" : "0", "Template" : "0", "YAML::XS" : "0.81", "perl" : "5.024" @@ -51,55 +53,59 @@ "provides" : { "App::DocKnot" : { "file" : "lib/App/DocKnot.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Command" : { "file" : "lib/App/DocKnot/Command.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Config" : { "file" : "lib/App/DocKnot/Config.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Dist" : { "file" : "lib/App/DocKnot/Dist.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Generate" : { "file" : "lib/App/DocKnot/Generate.pm", - "version" : "6.00" + "version" : "6.01" + }, + "App::DocKnot::Release" : { + "file" : "lib/App/DocKnot/Release.pm", + "version" : "6.01" }, "App::DocKnot::Spin" : { "file" : "lib/App/DocKnot/Spin.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Spin::Pointer" : { "file" : "lib/App/DocKnot/Spin/Pointer.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Spin::RSS" : { "file" : "lib/App/DocKnot/Spin/RSS.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Spin::Sitemap" : { "file" : "lib/App/DocKnot/Spin/Sitemap.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Spin::Thread" : { "file" : "lib/App/DocKnot/Spin/Thread.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Spin::Versions" : { "file" : "lib/App/DocKnot/Spin/Versions.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Update" : { "file" : "lib/App/DocKnot/Update.pm", - "version" : "6.00" + "version" : "6.01" }, "App::DocKnot::Util" : { "file" : "lib/App/DocKnot/Util.pm", - "version" : "6.00" + "version" : "6.01" } }, "release_status" : "stable", @@ -117,6 +123,6 @@ "web" : "https://github.com/rra/docknot" } }, - "version" : "6.00", + "version" : "6.01", "x_serialization_backend" : "JSON::PP version 4.04" } @@ -17,43 +17,46 @@ name: App-DocKnot provides: App::DocKnot: file: lib/App/DocKnot.pm - version: '6.00' + version: '6.01' App::DocKnot::Command: file: lib/App/DocKnot/Command.pm - version: '6.00' + version: '6.01' App::DocKnot::Config: file: lib/App/DocKnot/Config.pm - version: '6.00' + version: '6.01' App::DocKnot::Dist: file: lib/App/DocKnot/Dist.pm - version: '6.00' + version: '6.01' App::DocKnot::Generate: file: lib/App/DocKnot/Generate.pm - version: '6.00' + version: '6.01' + App::DocKnot::Release: + file: lib/App/DocKnot/Release.pm + version: '6.01' App::DocKnot::Spin: file: lib/App/DocKnot/Spin.pm - version: '6.00' + version: '6.01' App::DocKnot::Spin::Pointer: file: lib/App/DocKnot/Spin/Pointer.pm - version: '6.00' + version: '6.01' App::DocKnot::Spin::RSS: file: lib/App/DocKnot/Spin/RSS.pm - version: '6.00' + version: '6.01' App::DocKnot::Spin::Sitemap: file: lib/App/DocKnot/Spin/Sitemap.pm - version: '6.00' + version: '6.01' App::DocKnot::Spin::Thread: file: lib/App/DocKnot/Spin/Thread.pm - version: '6.00' + version: '6.01' App::DocKnot::Spin::Versions: file: lib/App/DocKnot/Spin/Versions.pm - version: '6.00' + version: '6.01' App::DocKnot::Update: file: lib/App/DocKnot/Update.pm - version: '6.00' + version: '6.01' App::DocKnot::Util: file: lib/App/DocKnot/Util.pm - version: '6.00' + version: '6.01' requires: Date::Parse: '0' File::BaseDir: '0' @@ -67,9 +70,11 @@ requires: JSON::MaybeXS: '0' Kwalify: '0' List::SomeUtils: '0.07' - Path::Tiny: '0' + Path::Iterator::Rule: '0' + Path::Tiny: '0.101' Perl6::Slurp: '0' - Pod::Thread: '3.00' + Pod::Thread: '3.01' + Sort::Versions: '0' Template: '0' YAML::XS: '0.81' perl: '5.024' @@ -78,5 +83,5 @@ resources: homepage: https://www.eyrie.org/~eagle/software/docknot license: http://www.opensource.org/licenses/mit-license.php repository: https://github.com/rra/docknot.git -version: '6.00' +version: '6.01' x_serialization_backend: 'CPAN::Meta::YAML version 0.018' @@ -1,8 +1,8 @@ - DocKnot 6.00 + DocKnot 6.01 (Static web site and documentation generator) Maintained by Russ Allbery <rra@cpan.org> - Copyright 1999-2021 Russ Allbery <rra@cpan.org>. This software is + Copyright 1999-2022 Russ Allbery <rra@cpan.org>. This software is distributed under a BSD-style license. Please see the section LICENSE below for more information. @@ -64,9 +64,11 @@ REQUIREMENTS * JSON::MaybeXS * Kwalify * List::SomeUtils 0.07 or later - * Path::Tiny + * Path::Iterator::Rule + * Path::Tiny 0.101 or later * Perl6::Slurp - * Pod::Thread 3.00 or later + * Pod::Thread 3.01 or later + * Sort::Versions * Template (part of Template Toolkit) * YAML::XS 0.81 or later @@ -158,7 +160,7 @@ LICENSE The DocKnot package as a whole is covered by the following copyright statement and license: - Copyright 1999-2021 Russ Allbery <rra@cpan.org> + Copyright 1999-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -8,7 +8,7 @@ version](https://img.shields.io/cpan/v/App-DocKnot)](https://metacpan.org/releas [![Debian package](https://img.shields.io/debian/v/docknot/unstable)](https://tracker.debian.org/pkg/docknot) -Copyright 1999-2021 Russ Allbery <rra@cpan.org>. This software is +Copyright 1999-2022 Russ Allbery <rra@cpan.org>. This software is distributed under a BSD-style license. Please see the section [License](#license) below for more information. @@ -69,9 +69,11 @@ The following additional Perl modules are required to use it: * JSON::MaybeXS * Kwalify * List::SomeUtils 0.07 or later -* Path::Tiny +* Path::Iterator::Rule +* Path::Tiny 0.101 or later * Perl6::Slurp -* Pod::Thread 3.00 or later +* Pod::Thread 3.01 or later +* Sort::Versions * Template (part of Template Toolkit) * YAML::XS 0.81 or later @@ -159,7 +161,7 @@ requests are gratefully reviewed and normally accepted. The DocKnot package as a whole is covered by the following copyright statement and license: -> Copyright 1999-2021 +> Copyright 1999-2022 > Russ Allbery <rra@cpan.org> > > Permission is hereby granted, free of charge, to any person obtaining a @@ -21,9 +21,6 @@ Metadata required for web pages: Metadata required for release: -* Path to Git repository -* Path to publication directory (from URL?) -* GPG key IDs for package signing * License overrides for build-license * Default license for build-license @@ -40,10 +37,8 @@ DocKnot itself: * Pull the version number of a package from a single source * Pull the synopsis for a Perl module from the Perl build system * Document the metadata format -* Incorporate spin into this package (and rewrite as Perl modules) * Incorporate cvs2xhtml into this package * Incorporate faq2html into this package (and rewrite as a Perl module) -* Incorporate release into this package * Incorporate build-license into this package (and rewrite as a Perl module) * Automatically run build-license during the release process * Add metadata files and overrides to build-license diff --git a/bin/docknot b/bin/docknot index c9e3f12..b091f4f 100755 --- a/bin/docknot +++ b/bin/docknot @@ -22,7 +22,7 @@ __END__ =for stopwords Allbery DocKnot docknot MERCHANTABILITY NONINFRINGEMENT sublicense subcommand -subcommands distdir pgp-key cl2xhtml cvs2xhtml faq2html spin-rss +subcommands distdir pgp-key cl2xhtml cvs2xhtml faq2html spin-rss archivedir =head1 NAME @@ -38,6 +38,8 @@ B<docknot> generate [B<-m> I<metadata>] [B<-w> I<width>] I<template> [I<output>] B<docknot> generate-all [B<-m> I<metadata>] [B<-w> I<width>] +B<docknot> release [B<-a> I<archivedir>] [B<-d> I<distdir>] [B<-m> I<metadata>] + B<docknot> spin [B<-d>] [B<-e> I<pattern> ...] [B<-s> I<url>] I<source> I<output> @@ -47,6 +49,8 @@ B<docknot> spin-thread [B<-f>] [B<-s> I<url>] [I<source> [I<output>]] B<docknot> update [B<-m> I<metadata>] [B<-o> I<output>] +B<docknot> update-spin [I<path>] + =head1 DESCRIPTION B<docknot> is a static web site generator with special support for managing @@ -77,6 +81,11 @@ Like C<generate>, but generates all of the package documentation for which default output files are configured. This is a quick short-cut to generating all documentation that's shipped with the package. +=item release + +Copy a distribution tarball into a release area, archiving old versions, and +optionally updating configuration for C<spin>. + =item spin Spin a tree of files written in the macro language thread into an HTML web @@ -97,11 +106,19 @@ Like C<spin>, but convert a single file written in thread to HTML. Update the DocKnot package configuration from an older format. +=item update-spin + +Update an input tree for C<spin> to the latest expectations. This will, for +example, convert old-style F<*.rpod> pointer files to new-style F<*.spin> +pointer files. + =back =head1 OPTIONS -Each B<docknot> subcommand takes its own options. +Each B<docknot> subcommand takes its own options. Many also read global +configuration options from DocKnot's configuration. See +L<App::DocKnot::Config/Global Configuration> for more details. =head2 Global Options @@ -129,11 +146,9 @@ or if this option is not set. =item B<-m> I<metadata>, B<--metadata>=I<metadata> -The path to the metadata files for the package whose distribution tarball is -being generated. This should be a directory containing all the package -metadata files required by App::DocKnot. Default: F<docs/docknot.yaml> -relative to the current directory (which is the recommended metadata path for -a project). +The path to the metadata file for the package whose distribution tarball is +being generated. Default: F<docs/docknot.yaml> relative to the current +directory (which is the recommended metadata path for a project). =item B<-p> I<pgp-key>, B<--pgp-key>=I<pgp-key> @@ -151,10 +166,9 @@ in the global DocKnot configuration file. =item B<-m> I<metadata>, B<--metadata>=I<metadata> -The path to the metadata files for the package whose documentation is being -generated. This should be a directory containing all the package metadata -files required by App::DocKnot. Default: F<docs/docknot.yaml> relative to the -current directory (which is the recommended metadata path for a project). +The path to the metadata file for the package whose documentation is being +generated. Default: F<docs/docknot.yaml> relative to the current directory +(which is the recommended metadata path for a project). =item B<-w> I<width>, B<--width>=I<width> @@ -182,10 +196,9 @@ If the template isn't listed above, this argument is required. =item B<-m> I<metadata>, B<--metadata>=I<metadata> -The path to the metadata files for the package whose documentation is being -generated. This should be a directory containing all the package metadata -files required by App::DocKnot. Default: F<docs/docknot.yaml> relative to the -current directory (which is the recommended metadata path for a project). +The path to the metadata file for the package whose documentation is being +generated. Default: F<docs/docknot.yaml> relative to the current directory +(which is the recommended metadata path for a project). =item B<-w> I<width>, B<--width>=I<width> @@ -193,6 +206,35 @@ Column width at which the generated output is wrapped. Default: 74. =back +=head2 release + +=over 4 + +=item B<-a> I<archivedir>, B<--archivedir>=I<archivedir> + +The release area into which to put the distribution tarball. The current +distribution will be put in a subdirectory named after the +C<distribution.section> key in the package configuration. Older versions will +be moved to the F<ARCHIVE> subdirectory of I<archivedir>. Default: The +C<archivedir> option in the global DocKnot configuration file. This option is +required if there is no configuration file or if this option is not set. + +=item B<-d> I<distdir>, B<--distdir>=I<distdir> + +The directory from which to get the new distribution tarball, normally +generated by C<dist>. The latest version in this directory will be used. +Default: The C<destdir> option in the global DocKnot configuration file. This +option is required if there is no configuration file or if this option is not +set. + +=item B<-m> I<metadata>, B<--metadata>=I<metadata> + +The path to the metadata file for the package whose distribution tarball is +being generated. Default: F<docs/docknot.yaml> relative to the current +directory (which is the recommended metadata path for a project). + +=back + =head2 spin =over 4 @@ -206,7 +248,8 @@ I<output> tree that do not have a corresponding file in the I<source> tree. =item B<-e> I<pattern>, B<--exclude>=I<pattern> Exclude files matching the given regular expression I<pattern> from being -converted. This flag may be used multiple times. +converted. The pattern is matched only against the file name, not its full +path. This flag may be given multiple times. =item B<-s> I<url>, B<--style-url>=I<url> @@ -285,6 +328,17 @@ recommended metadata path for a project). =back +=head2 update-spin + +=over 4 + +=item I<path> + +The path to the spin input tree to update. If not given, defaults to the +current directory. + +=back + =head1 DIAGNOSTICS If B<docknot> fails with errors, see the underlying module for that subcommand @@ -320,7 +374,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2016, 2018-2021 Russ Allbery <rra@cpan.org> +Copyright 2016, 2018-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -12,9 +12,11 @@ requires 'IPC::System::Simple'; requires 'JSON::MaybeXS'; requires 'Kwalify'; requires 'List::SomeUtils', '0.07'; -requires 'Path::Tiny'; +requires 'Path::Iterator::Rule'; +requires 'Path::Tiny', '0.101'; requires 'Perl6::Slurp'; -requires 'Pod::Thread', '3.00'; +requires 'Pod::Thread', '3.01'; +requires 'Sort::Versions'; requires 'Template'; requires 'YAML::XS', '0.81'; diff --git a/docs/docknot.yaml b/docs/docknot.yaml index becc165..0103185 100644 --- a/docs/docknot.yaml +++ b/docs/docknot.yaml @@ -15,14 +15,14 @@ format: v1 name: DocKnot maintainer: Russ Allbery <rra@cpan.org> -version: '6.00' +version: '6.01' synopsis: Static web site and documentation generator license: name: Expat copyrights: - holder: Russ Allbery <rra@cpan.org> - years: 1999-2021 + years: 1999-2022 build: type: Module::Build @@ -141,9 +141,11 @@ requirements: | * JSON::MaybeXS * Kwalify * List::SomeUtils 0.07 or later - * Path::Tiny + * Path::Iterator::Rule + * Path::Tiny 0.101 or later * Perl6::Slurp - * Pod::Thread 3.00 or later + * Pod::Thread 3.01 or later + * Sort::Versions * Template (part of Template Toolkit) * YAML::XS 0.81 or later diff --git a/lib/App/DocKnot.pm b/lib/App/DocKnot.pm index 721bb30..c288ed9 100644 --- a/lib/App/DocKnot.pm +++ b/lib/App/DocKnot.pm @@ -11,7 +11,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot 6.00; +package App::DocKnot 6.01; use 5.024; use autodie; @@ -70,10 +70,21 @@ sub load_yaml_file { # data elements set to false. local $YAML::XS::Boolean = 'JSON::PP'; - # Load the metadata and check it against the schema. - my $data_ref = YAML::XS::LoadFile($path); + # Load the metadata and check it against the schema. YAML::XS for some + # reason puts a newline before the system error part of an error message + # when loading a file, so clean up the error a bit. my $schema_path = $self->appdata_path('schema', $schema . '.yaml'); - my $schema_ref = YAML::XS::LoadFile($schema_path); + my ($data_ref, $schema_ref); + eval { + $data_ref = YAML::XS::LoadFile($path); + $schema_ref = YAML::XS::LoadFile($schema_path); + }; + if ($@) { + my $error = lcfirst($@); + chomp($error); + $error =~ s{ \n }{ }xms; + die "$error\n"; + } eval { validate($schema_ref, $data_ref) }; if ($@) { my $errors = $@; diff --git a/lib/App/DocKnot/Command.pm b/lib/App/DocKnot/Command.pm index 7ef45e5..f032ab6 100644 --- a/lib/App/DocKnot/Command.pm +++ b/lib/App/DocKnot/Command.pm @@ -10,7 +10,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Command 6.00; +package App::DocKnot::Command 6.01; use 5.024; use autodie; @@ -78,6 +78,12 @@ our %COMMANDS = ( options => ['metadata|m=s', 'width|w=i'], maximum => 0, }, + release => { + method => 'release', + module => 'App::DocKnot::Release', + options => ['archivedir|a=s', 'distdir|d=s', 'metadata|m=s'], + maximum => 0, + }, spin => { method => 'spin', module => 'App::DocKnot::Spin', @@ -104,6 +110,11 @@ our %COMMANDS = ( options => ['metadata|m=s', 'output|o=s'], maximum => 0, }, + 'update-spin' => { + method => 'update_spin', + module => 'App::DocKnot::Update', + maximum => 1, + }, ); ############################################################################## @@ -297,8 +308,9 @@ Perl 5.24 or later and the modules Date::Language, Date::Parse (both part of TimeDate), File::BaseDir, File::ShareDir, Git::Repository, Image::Size, IO::Compress::Xz (part of IO-Compress-Lzma), IO::Uncompress::Gunzip (part of IO-Compress), IPC::Run, IPC::System::Simple, JSON::MaybeXS, Kwalify, -List::SomeUtils, Path::Tiny, Perl6::Slurp, Template (part of Template -Toolkit), and YAML::XS, all of which are available from CPAN. +List::SomeUtils, Path::Iterator::Rule, Path::Tiny, Perl6::Slurp, Template +(part of Template Toolkit), and YAML::XS, all of which are available from +CPAN. =head1 DESCRIPTION @@ -337,7 +349,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2018-2021 Russ Allbery <rra@cpan.org> +Copyright 2018-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/App/DocKnot/Config.pm b/lib/App/DocKnot/Config.pm index 2eebe32..6b6716d 100644 --- a/lib/App/DocKnot/Config.pm +++ b/lib/App/DocKnot/Config.pm @@ -9,7 +9,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Config 6.00; +package App::DocKnot::Config 6.01; use 5.024; use autodie; @@ -31,18 +31,9 @@ use YAML::XS (); # metadata - Path to the docknot.yaml file # # Returns: Newly created object -# Throws: Text exceptions on invalid metadata directory path sub new { my ($class, $args_ref) = @_; - - # Ensure we were given a valid metadata argument. - my $metadata = $args_ref->{metadata} // 'docs/docknot.yaml'; - if (!-e $metadata) { - croak("metadata path $metadata does not exist"); - } - - # Create and return the object. - my $self = { metadata => $metadata }; + my $self = { metadata => $args_ref->{metadata} // 'docs/docknot.yaml' }; bless($self, $class); return $self; } @@ -116,7 +107,7 @@ __END__ =for stopwords Allbery DocKnot MERCHANTABILITY NONINFRINGEMENT sublicense CPAN XDG Kwalify -distdir +distdir archivedir =head1 NAME @@ -179,6 +170,16 @@ default) may contain the following keys: =over 4 +=item archivedir + +Specifies the directory into which distribution tarballs are placed by the +C<docknot release> command. The current distribution will be put in a +subdirectory named after the C<distribution.section> key in the package +configuration. Older versions will be moved to the F<ARCHIVE> subdirectory of +I<archivedir>. + +If this is not specified, the B<-a> option to C<docknot release> is mandatory. + =item distdir Specifies the directory into which to build and store distribution tarballs @@ -186,7 +187,8 @@ Specifies the directory into which to build and store distribution tarballs as working directories while the distribution is being built, and the final tarballs are stored in this directory. -If this is not specified, the B<-d> option to C<docknot dist> is mandatory. +If this is not specified, the B<-d> options to C<docknot dist> and C<docknot +release> is mandatory. =item pgp_key @@ -237,7 +239,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2013-2021 Russ Allbery <rra@cpan.org> +Copyright 2013-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/App/DocKnot/Dist.pm b/lib/App/DocKnot/Dist.pm index 50d3b9b..ccbeff4 100644 --- a/lib/App/DocKnot/Dist.pm +++ b/lib/App/DocKnot/Dist.pm @@ -10,7 +10,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Dist 6.00; +package App::DocKnot::Dist 6.01; use 5.024; use autodie; @@ -262,9 +262,9 @@ sub _sign_tarballs { # Create a new App::DocKnot::Dist object, which will be used for subsequent # calls. # -# $args - Anonymous hash of arguments with the following keys: +# $args_ref - Anonymous hash of arguments with the following keys: # distdir - Path to the directory for distribution tarball -# metadata - Path to the directory containing package metadata +# metadata - Path to the package metadata # perl - Path to Perl to use (default: search the user's PATH) # # Returns: Newly created object @@ -520,7 +520,7 @@ Default: The binary named C<gpg> on the user's PATH. =item metadata -The path to the directory containing metadata for a package. Default: +The path to the metadata for the package on which to operate. Default: F<docs/docknot.yaml> relative to the current directory. =item perl @@ -598,7 +598,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2019-2021 Russ Allbery <rra@cpan.org> +Copyright 2019-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/App/DocKnot/Generate.pm b/lib/App/DocKnot/Generate.pm index 8caa7a8..cab4365 100644 --- a/lib/App/DocKnot/Generate.pm +++ b/lib/App/DocKnot/Generate.pm @@ -10,7 +10,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Generate 6.00; +package App::DocKnot::Generate 6.01; use 5.024; use autodie; @@ -18,6 +18,7 @@ use parent qw(App::DocKnot); use warnings; use App::DocKnot::Config; +use App::DocKnot::Util qw(print_fh); use Carp qw(croak); use Encode qw(encode); use Template; @@ -525,9 +526,9 @@ sub generate_output { } # Generate the output. + my $data = $self->generate($template); open(my $outfh, '>', $output); - print {$outfh} encode('utf-8', $self->generate($template)) - or croak("cannot write to $output: $!"); + print_fh($outfh, $output, encode('utf-8', $data)); close($outfh); return; } diff --git a/lib/App/DocKnot/Release.pm b/lib/App/DocKnot/Release.pm new file mode 100644 index 0000000..b5d8d83 --- /dev/null +++ b/lib/App/DocKnot/Release.pm @@ -0,0 +1,268 @@ +# Release a distribution tarball for a package. +# +# This is the implementation of the docknot release command, which copies a +# release tarball (normally generated by docknot dist) into a publication +# area, archives old versions, and updates the .versions database for spin. +# +# SPDX-License-Identifier: MIT + +############################################################################## +# Modules and declarations +############################################################################## + +package App::DocKnot::Release 6.01; + +use 5.024; +use autodie; +use warnings; + +use App::DocKnot::Config; +use App::DocKnot::Spin::Versions; +use App::DocKnot::Util qw(latest_tarball); +use Carp qw(croak); +use Path::Tiny qw(path); + +############################################################################## +# Public interface +############################################################################## + +# Create a new App::DocKnot::Release object, which will be used for subsequent +# calls. +# +# $args_ref - Anonymous hash of arguments with the following keys: +# archivedir - Path to the archive directory +# distdir - Path to where docknot dist puts distribution tarballs +# metadata - Path to the package metadata +# +# Returns: Newly created object +# Throws: Text exceptions on invalid package metadata +# Text exceptions on invalid global configuration +# Text exceptions on invalid distdir argument +sub new { + my ($class, $args_ref) = @_; + + # Create the config reader. + my %config_args; + if ($args_ref->{metadata}) { + $config_args{metadata} = $args_ref->{metadata}; + } + my $config_reader = App::DocKnot::Config->new(\%config_args); + + # Load the global and package configuration. + my $global_config_ref = $config_reader->global_config(); + my $config_ref = $config_reader->config(); + + # Ensure we were given a valid archivedir and distdir arguments if they + # were not set in the global configuration. + my $archivedir = $args_ref->{archivedir} + // $global_config_ref->{archivedir}; + if (!defined($archivedir)) { + croak('archivedir path not given'); + } elsif (!-d $archivedir) { + croak( + "archivedir path $archivedir does not exist or is not a directory", + ); + } + my $distdir = $args_ref->{distdir} // $global_config_ref->{distdir}; + if (!defined($distdir)) { + croak('distdir path not given'); + } elsif (!-d $distdir) { + croak("distdir path $distdir does not exist or is not a directory"); + } + + # Build an App::DocKnot::Spin::Versions object if configured with a path + # to a versions database. + my $versions; + if ($global_config_ref->{versions}) { + my $versions_path = path($global_config_ref->{versions}); + $versions = App::DocKnot::Spin::Versions->new($versions_path); + } + + # Create and return the object. + #<<< + my $self = { + archivedir => path($archivedir), + distdir => path($distdir), + package => $config_ref->{name}, + section => $config_ref->{distribution}{section}, + tarname => $config_ref->{distribution}{tarname}, + version_name => $config_ref->{distribution}{version}, + versions => $versions, + }; + #>>> + bless($self, $class); + return $self; +} + +# Release a new version and update .versions if so configured. +# +# Throws: Text exception on any failures +sub release { + my ($self) = @_; + my $tarball_ref = latest_tarball($self->{distdir}, $self->{tarname}); + if (!defined($tarball_ref)) { + croak("no release of $self->{tarname} found in $self->{distdir}"); + } + + # Archive old versions. This is only done if the current version in the + # archive directory is different than the version we're about to release. + # If it is not, we overwrite the version in the archive directory, since + # we assume we're replacing a release. + my $current_path = $self->{archivedir}->child($self->{section}); + my $current_ref = latest_tarball($current_path, $self->{tarname}); + if (defined($current_ref)) { + if ($current_ref->{version} ne $tarball_ref->{version}) { + my $old_root = $self->{archivedir}->child('ARCHIVE'); + my $old_path = $old_root->child($self->{tarname}); + $old_path->mkpath(); + for my $file ($current_ref->{files}->@*) { + $current_path->child($file)->move($old_path->child($file)); + } + } + } + + # Copy the new version into place and update the symlinks. + $current_path->mkpath(); + for my $file ($tarball_ref->{files}->@*) { + $self->{distdir}->child($file)->copy($current_path->child($file)); + my $generic_name = $file; + $generic_name =~ s{ \A (\Q$self->{tarname}\E) - [\d.]+ [.] }{$1.}xms; + my $generic_path = $current_path->child($generic_name); + $generic_path->remove(); + symlink($file, $generic_path); + } + + # Update the .versions file. + if ($self->{versions}) { + my $name = $self->{version_name}; + my $version = $tarball_ref->{version}; + my $date = $tarball_ref->{date}; + $self->{versions}->update_version($name, $version, $date); + } + return; +} + +############################################################################## +# Module return value and documentation +############################################################################## + +1; +__END__ + +=for stopwords +Allbery DocKnot MERCHANTABILITY NONINFRINGEMENT sublicense archivedir distdir + +=head1 Name + +App::DocKnot::Release - Release a distribution tarball + +=head1 SYNOPSIS + + use App::DocKnot::Release; + my $docknot = App::DocKnot::Release->new(); + $docknot->release(); + +=head1 REQUIREMENTS + +Perl 5.24 or later and the modules File::BaseDir, File::ShareDir, +Git::Repository, Path::Tiny, and YAML::XS, all of which are available from +CPAN. + +=head1 DESCRIPTION + +This component of DocKnot releases a distribution tarball (normally created by +C<docknot dist> or App::DocKnot::Dist), maintains a software distribution +directory, and updates a version and release date database. + +=head1 CLASS METHODS + +=over 4 + +=item new(ARGS) + +Create a new App::DocKnot::Release object. This should be used for all +subsequent actions. ARGS should be a hash reference with one or more of the +following keys: + +=over 4 + +=item archivedir + +The release area into which to put the distribution tarball. The current +distribution will be put in a subdirectory named after the +C<distribution.section> key in the package configuration. Older versions will +be moved to the F<ARCHIVE> subdirectory of I<archivedir>. Required if not set +in the global configuration file. + +=item distdir + +The directory from which to get the new distribution tarball, normally +generated by C<docknot dist>. The latest version in this directory will be +used. Required if not set in the global configuration file. + +=item metadata + +The path to the metadata for the package on which to operate. Default: +F<docs/docknot.yaml> relative to the current directory. + +=back + +=back + +=head1 INSTANCE METHODS + +=over 4 + +=item release() + +Copy the distribution tarball (in multiple formats, with PGP signatures) into +a release area, updates symlink pointing to the latest version, and move any +old release to an archive area. + +If C<versions> is set in the global configuration file, updates the +F<.versions> file found at that path with the new release version and release +date. See L<App::DocKnot::Spin::Versions> for more information about +F<.versions> files. + +=back + +=head1 AUTHOR + +Russ Allbery <rra@cpan.org> + +=head1 COPYRIGHT AND LICENSE + +Copyright 2022 Russ Allbery <rra@cpan.org> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=head1 SEE ALSO + +L<docknot(1)>, L<App::DocKnot::Config>, L<App::DocKnot::Dist>, +L<App::DocKnot::Spin::Versions> + +This module is part of the App-DocKnot distribution. The current version of +DocKnot is available from CPAN, or directly from its web site at +L<https://www.eyrie.org/~eagle/software/docknot/>. + +=cut + +# Local Variables: +# copyright-at-end-flag: t +# End: diff --git a/lib/App/DocKnot/Spin.pm b/lib/App/DocKnot/Spin.pm index 73666e0..5f4632e 100644 --- a/lib/App/DocKnot/Spin.pm +++ b/lib/App/DocKnot/Spin.pm @@ -11,7 +11,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Spin 6.00; +package App::DocKnot::Spin 6.01; use 5.024; use autodie; @@ -23,15 +23,11 @@ use App::DocKnot::Spin::Sitemap; use App::DocKnot::Spin::Thread; use App::DocKnot::Spin::Versions; use App::DocKnot::Util qw(is_newer print_checked print_fh); -use Carp qw(croak); -use Cwd qw(getcwd realpath); -use File::Basename qw(fileparse); -use File::Copy qw(copy); -use File::Find qw(find finddepth); -use File::Spec (); use Git::Repository (); use IPC::System::Simple qw(capture); -use Pod::Thread 3.00 (); +use Path::Iterator::Rule (); +use Path::Tiny qw(path); +use Pod::Thread 3.01 (); use POSIX qw(strftime); # The default list of files and/or directories to exclude from spinning. This @@ -53,8 +49,8 @@ my $URL = 'https://www.eyrie.org/~eagle/software/web/'; # Build te page footer, which consists of the navigation links, the regular # signature, and the last modified date. # -# $source - Full path to the source file -# $out_path - Full path to the output file +# $source - Path::Tiny path to the source file +# $out_path - Path::Tiny path to the output file # $id - CVS Id of the source file or undef if not known # @templates - Two templates to use. The first will be used if the # modification and current dates are the same, and the second @@ -67,15 +63,14 @@ sub _footer { my ($self, $source, $out_path, $id, @templates) = @_; my $output = q{}; my $in_tree = 0; - if ($self->{source} && $source =~ m{ \A \Q$self->{source}\E }xms) { + if ($self->{source} && $self->{source}->subsumes($source)) { $in_tree = 1; } # Add the end-of-page navbar if we have sitemap information. if ($self->{sitemap} && $self->{output}) { - my $page = $out_path; - $page =~ s{ \A \Q$self->{output}\E }{}xms; - $output .= join(q{}, $self->{sitemap}->navbar($page)) . "\n"; + my $page = $out_path->relative($self->{output}); + $output .= join(q{}, $self->{sitemap}->navbar("/$page")) . "\n"; } # Figure out the modification dates. Use the RCS/CVS Id if available, @@ -88,13 +83,13 @@ sub _footer { } } elsif ($self->{repository} && $in_tree) { $modified - = $self->{repository}->run('log', '-1', '--format=%ct', $source); + = $self->{repository}->run('log', '-1', '--format=%ct', "$source"); if ($modified) { $modified = strftime('%Y-%m-%d', gmtime($modified)); } } if (!$modified) { - $modified = strftime('%Y-%m-%d', gmtime((stat $source)[9])); + $modified = strftime('%Y-%m-%d', gmtime($source->stat()->[9])); } my $now = strftime('%Y-%m-%d', gmtime()); @@ -121,9 +116,8 @@ sub _footer { # the output of an external converter. sub _write_converter_output { my ($self, $page_ref, $output, $footer) = @_; - my $page = $output; - $page =~ s{ \A \Q$self->{output}\E }{}xms; - open(my $out_fh, '>', $output); + my $page = $output->relative($self->{output}); + my $out_fh = $output->openw_utf8(); # Grab the first few lines of input, looking for a blurb and Id string. # Give up if we encounter <body> first. Also look for a </head> tag and @@ -211,7 +205,8 @@ sub _cvs2xhtml { $style ||= $self->{style_url} . 'cvs.css'; # Separate the source file into a directory and filename. - my ($name, $dir) = fileparse($source); + my $name = $source->basename(); + my $dir = $source->parent(); # Construct the options to cvs2xhtml. if ($options !~ m{ -n [ ] }xms) { @@ -274,14 +269,12 @@ sub _pod2html { # Grab the thread output. my $data; $podthread->output_string(\$data); - $podthread->parse_file($source); + $podthread->parse_file("$source"); # Spin that thread into HTML. my $page = $self->{thread}->spin_thread($data); # Push the result through _write_converter_output. - my $file = $source; - $file =~ s{ [.] [^.]+ \z }{.html}xms; my $footer = sub { my ($blurb) = @_; my $link = '<a href="%URL%">spun</a>'; @@ -305,7 +298,7 @@ sub _pod2html { # Given a pointer file, read the master file name and any options, returning # them as a list with the newlines chomped off. # -# $file - The path to the file to read +# $file - Path::Tiny for the file to read # # Returns: List of the master file, any command-line options, and the style # sheet to use, as strings @@ -315,11 +308,7 @@ sub _read_pointer { my ($self, $file) = @_; # Read the pointer file. - open(my $pointer, '<', $file); - my $master = <$pointer>; - my $options = <$pointer>; - my $style = <$pointer>; - close($pointer); + my ($master, $options, $style) = $file->lines_utf8(); # Clean up the contents. if (!$master) { @@ -339,29 +328,42 @@ sub _read_pointer { return ($master, $options, $style); } -# This routine is called by File::Find for every file in the source tree. It -# decides what to do with each file, whether spinning it or copying it. +# Convert an input path to an output path. +# +# $input - Path::Tiny input path +# $extension - If given, remove this extension and add .html in its place +sub _output_for_file { + my ($self, $input, $extension) = @_; + my $output = $input->relative($self->{source})->absolute($self->{output}); + if ($extension) { + my $output_file = $input->basename($extension) . '.html'; + $output = $output->sibling($output_file); + } + return $output; +} + +# Report an action to standard output. +# +# $action - String description of the action +# $output - Output file generated +sub _report_action { + my ($self, $action, $output) = @_; + my $shortout = $output->relative($self->{output}); + print_checked("$action .../$shortout\n"); + return; +} + +# This routine is called for every file in the source tree. It decides what +# to do with each file, whether spinning it or copying it. +# +# $input - Path::Tiny path to the input file # # Throws: Text exception on any processing error # autodie exception if files could not be accessed or written # ## no critic (Subroutines::ProhibitExcessComplexity) sub _process_file { - my ($self) = @_; - my $file = $_; - return if $file eq q{.}; - for my $regex ($self->{excludes}->@*) { - if ($file =~ m{$regex}xms) { - $File::Find::prune = 1; - return; - } - } - my $input = $File::Find::name; - my $output = $input; - $output =~ s{ \A \Q$self->{source}\E }{$self->{output}}xms - or die "input file $file out of tree\n"; - my $shortout = $output; - $shortout =~ s{ \A \Q$self->{output}\E }{...}xms; + my ($self, $input) = @_; # Conversion rules for pointers. The key is the extension, the first # value is the name of the command for the purposes of output, and the @@ -376,87 +378,84 @@ sub _process_file { #>>> # Figure out what to do with the input. - if (-d $file) { - $self->{generated}{$output} = 1; - if (-e $output && !-d $output) { + if ($input->is_dir()) { + my $output = $self->_output_for_file($input); + $self->{generated}{"$output"} = 1; + if ($output->exists() && !$output->is_dir()) { die "cannot replace $output with a directory\n"; - } elsif (!-d $output) { - print_checked("Creating $shortout\n"); - mkdir($output, 0755); + } elsif (!$output->is_dir()) { + $self->_report_action('Creating', $output); + $output->mkpath(); } - my $rss_path = File::Spec->catfile($file, '.rss'); - if (-e $rss_path) { - $self->{rss}->generate($rss_path, $file); + my $rss_path = path($input, '.rss'); + if ($rss_path->exists()) { + $self->{rss}->generate("$rss_path", "$input"); } - } elsif ($file =~ m{ [.] spin \z }xms) { - $output =~ s{ [.] spin \z }{.html}xms; - $shortout =~ s{ [.] spin \z }{.html}xms; - $self->{generated}{$output} = 1; - if ($self->{pointer}->is_out_of_date($input, $output)) { - print_checked("Converting $shortout\n"); - $self->{pointer}->spin_pointer($input, $output); + } elsif ($input->basename() =~ m{ [.] spin \z }xms) { + my $output = $self->_output_for_file($input, '.spin'); + $self->{generated}{"$output"} = 1; + if ($self->{pointer}->is_out_of_date("$input", "$output")) { + $self->_report_action('Converting', $output); + $self->{pointer}->spin_pointer("$input", "$output"); } - } elsif ($file =~ m{ [.] th \z }xms) { - $output =~ s{ [.] th \z }{.html}xms; - $shortout =~ s{ [.] th \z }{.html}xms; - $self->{generated}{$output} = 1; + } elsif ($input->basename() =~ m{ [.] th \z }xms) { + my $output = $self->_output_for_file($input, '.th'); + $self->{generated}{"$output"} = 1; # See if we're forced to regenerate the file because it is affected by # a software release. - if (-e $output && $self->{versions}) { - my $relative = $input; - $relative =~ s{ ^ \Q$self->{source}\E / }{}xms; - my $time = $self->{versions}->latest_release($relative); - return if is_newer($output, $file) && (stat($output))[9] >= $time; + if ($output->exists() && $self->{versions}) { + my $relative = $input->relative($self->{source}); + my $time = $self->{versions}->latest_release("$relative"); + return + if is_newer("$output", "$input") + && $output->stat()->[9] >= $time; } else { - return if is_newer($output, $file); + return if is_newer("$output", "$input"); } # The output file is not newer. Respin it. - print_checked("Spinning $shortout\n"); + $self->_report_action('Spinning', $output); $self->{thread}->spin_thread_file($input, $output); } else { - my ($extension) = ($file =~ m{ [.] ([^.]+) \z }xms); + my ($extension) = ($input->basename =~ m{ [.] ([^.]+) \z }xms); if (defined($extension) && $rules{$extension}) { my ($name, $sub) = $rules{$extension}->@*; - $output =~ s{ [.] \Q$extension\E \z }{.html}xms; - $shortout =~ s{ [.] \Q$extension\E \z }{.html}xms; - $self->{generated}{$output} = 1; + my $output = $self->_output_for_file($input, $extension); + $self->{generated}{"$output"} = 1; my ($source, $options, $style) = $self->_read_pointer($input); return if is_newer($output, $input, $source); - print_checked("Running $name for $shortout\n"); + $self->_report_action("Running $name for", $output); $self->$sub($source, $output, $options, $style); } else { - $self->{generated}{$output} = 1; - return if is_newer($output, $file); - print_checked("Updating $shortout\n"); - copy($file, $output) - or die "copy of $input to $output failed: $!\n"; + my $output = $self->_output_for_file($input); + $self->{generated}{"$output"} = 1; + return if is_newer("$output", "$input"); + $self->_report_action('Updating', $output); + $input->copy($output); } } return; } ## use critic -# This routine is called by File::Find for every file in the destination tree -# in depth-first order, if the user requested file deletion of files not -# generated from the source tree. It checks each file to see if it is in the -# $self->{generated} hash that was generated during spin processing, and if -# not, removes it. +# This routine is called for every file in the destination tree in depth-first +# order, if the user requested file deletion of files not generated from the +# source tree. It checks each file to see if it is in the $self->{generated} +# hash that was generated during spin processing, and if not, removes it. +# +# $file - Path::Tiny path to the file # # Throws: autodie exception on failure of rmdir or unlink sub _delete_files { - my ($self) = @_; - return if $_ eq q{.}; - my $file = $File::Find::name; - return if $self->{generated}{$file}; - my $shortfile = $file; - $shortfile =~ s{ ^ \Q$self->{output}\E }{...}xms; - print_checked("Deleting $shortfile\n"); - if (-d $file) { + my ($self, $file) = @_; + return if $self->{generated}{"$file"}; + my $shortfile = $file->relative($self->{output}); + print_checked("Deleting .../$shortfile\n"); + if ($file->is_dir()) { rmdir($file); } else { - unlink($file); + $file->remove(); } return; } @@ -495,7 +494,6 @@ sub new { my $self = { delete => $args_ref->{delete}, excludes => [@excludes], - rss => App::DocKnot::Spin::RSS->new(), style_url => $style_url, }; #>>> @@ -514,44 +512,47 @@ sub spin { # Reset data from a previous run. delete $self->{repository}; + delete $self->{rss}; delete $self->{sitemap}; delete $self->{versions}; # Canonicalize and check input. - $input = realpath($input) or die "cannot canonicalize $input: $!\n"; - if (!-d $input) { + $input = path($input)->realpath(); + if (!$input->is_dir()) { die "input tree $input must be a directory\n"; } $self->{source} = $input; # Canonicalize and check output. - if (!-d $output) { - print_checked("Creating $output\n"); - mkdir($output, 0755); + $output = path($output); + if (!$output->is_dir()) { + for my $created ($output->mkpath()) { + print_checked("Creating $created\n"); + } } - $output = realpath($output) or die "cannot canonicalize $output: $!\n"; + $output = $output->realpath(); $self->{output} = $output; # Read metadata from the top of the input directory. - my $sitemap_path = File::Spec->catfile($input, '.sitemap'); - if (-e $sitemap_path) { - $self->{sitemap} = App::DocKnot::Spin::Sitemap->new($sitemap_path); + my $sitemap_path = $input->child('.sitemap'); + if ($sitemap_path->exists()) { + $self->{sitemap} = App::DocKnot::Spin::Sitemap->new("$sitemap_path"); } - my $versions_path = File::Spec->catfile($input, '.versions'); - if (-e $versions_path) { + my $versions_path = $input->child('.versions'); + if ($versions_path->exists()) { $self->{versions} = App::DocKnot::Spin::Versions->new($versions_path); } - if (-d File::Spec->catdir($input, '.git')) { + if ($input->child('.git')->is_dir()) { $self->{repository} = Git::Repository->new(work_tree => $input); } + # Create a new RSS generator object. + $self->{rss} = App::DocKnot::Spin::RSS->new({ base => $input }); + # Process an .rss file at the top of the tree, if present. - my $rss_path = File::Spec->catfile($input, '.rss'); - if (-e $rss_path) { - my $cwd = getcwd(); - chdir($input); - $self->{rss}->generate($rss_path); - chdir($cwd); + my $rss_path = $input->child('.rss'); + if ($rss_path->exists()) { + $self->{rss}->generate("$rss_path", "$input"); } # Create a new thread converter object. @@ -571,7 +572,7 @@ sub spin { #<<< $self->{pointer} = App::DocKnot::Spin::Pointer->new( { - output => $output, + output => "$output", sitemap => $self->{sitemap}, 'style-url' => $self->{style_url}, thread => $self->{thread}, @@ -580,11 +581,20 @@ sub spin { #>>> # Process the input tree. - my $preprocess = sub { my @files = sort(@_); return @files }; - my $wanted = sub { $self->_process_file(@_) }; - find({ preprocess => $preprocess, wanted => $wanted }, $input); + my $rule = Path::Iterator::Rule->new(); + $rule = $rule->skip($rule->new()->name($self->{excludes}->@*)); + my $iter = $rule->iter("$input", { follow_symlinks => 0 }); + while (defined(my $file = $iter->())) { + $self->_process_file(path($file)); + } + + # Remove stray files from the output tree. if ($self->{delete}) { - finddepth(sub { $self->_delete_files(@_) }, $output); + my %options = (depthfirst => 1, follow_symlinks => 0); + $iter = $rule->iter("$output", \%options); + while (defined(my $file = $iter->())) { + $self->_delete_files(path($file)); + } } return; } @@ -614,10 +624,10 @@ App::DocKnot::Spin - Static site builder supporting thread macro language =head1 REQUIREMENTS Perl 5.24 or later and the modules Git::Repository, Image::Size, -List::SomeUtils, Path::Tiny, Pod::Thread, Template (part of Template Toolkit), -and YAML::XS, all of which are available from CPAN. Also expects to find -B<faq2html>, B<cvs2xhtml>, and B<cl2xhtml> on the user's PATH to convert -certain types of files. +List::SomeUtils, Path::Iterator::Rule, Path::Tiny, Pod::Thread, Template (part +of Template Toolkit), and YAML::XS, all of which are available from CPAN. +Also expects to find B<faq2html>, B<cvs2xhtml>, and B<cl2xhtml> on the user's +PATH to convert certain types of files. =head1 DESCRIPTION @@ -730,7 +740,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 1999-2011, 2013, 2021 Russ Allbery <rra@cpan.org> +Copyright 1999-2011, 2013, 2021-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/App/DocKnot/Spin/Pointer.pm b/lib/App/DocKnot/Spin/Pointer.pm index 05aa9ac..871e267 100644 --- a/lib/App/DocKnot/Spin/Pointer.pm +++ b/lib/App/DocKnot/Spin/Pointer.pm @@ -10,7 +10,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Spin::Pointer 6.00; +package App::DocKnot::Spin::Pointer 6.01; use 5.024; use autodie; @@ -18,12 +18,13 @@ use parent qw(App::DocKnot); use warnings; use App::DocKnot::Config; -use App::DocKnot::Util qw(is_newer print_fh); +use App::DocKnot::Util qw(is_newer); use Carp qw(croak); -use Encode qw(decode encode); +use Encode qw(decode); use File::BaseDir qw(config_files); use IPC::System::Simple qw(capturex); -use Kwalify qw(validate); +use Path::Tiny qw(path); +use Pod::Thread 3.01 (); use POSIX qw(strftime); use Template (); use YAML::XS (); @@ -41,12 +42,13 @@ my $URL = 'https://www.eyrie.org/~eagle/software/web/'; # $data_ref - Data from the pointer file # path - Path to the Markdown file to convert # style - Style sheet to use +# $base - Base path of pointer file (for relative paths) # $output - Path to the output file # # Throws: Text exception on conversion failure sub _spin_markdown { - my ($self, $data_ref, $output) = @_; - my $source = $data_ref->{path}; + my ($self, $data_ref, $base, $output) = @_; + my $source = path($data_ref->{path})->absolute($base); # Do the Markdown conversion using pandoc. my $html = capturex( @@ -63,13 +65,12 @@ sub _spin_markdown { # Construct the template variables. my ($links, $navbar, $style); if ($self->{sitemap}) { - my $page = $output; - $page =~ s{ \A \Q$self->{output}\E }{}xms; - my @links = $self->{sitemap}->links($page); + my $page = $output->relative($self->{output}); + my @links = $self->{sitemap}->links("/$page"); if (@links) { $links = join(q{}, @links); } - my @navbar = $self->{sitemap}->navbar($page); + my @navbar = $self->{sitemap}->navbar("/$page"); if (@navbar) { $navbar = join(q{}, @navbar); } @@ -82,7 +83,7 @@ sub _spin_markdown { docknot_url => $URL, html => decode('utf-8', $html), links => $links, - modified => strftime('%Y-%m-%d', gmtime((stat($source))[9])), + modified => strftime('%Y-%m-%d', gmtime($source->stat()->[9])), navbar => $navbar, now => strftime('%Y-%m-%d', gmtime()), style => $style, @@ -96,9 +97,7 @@ sub _spin_markdown { or croak($self->{template}->error()); # Write the result to the output file. - open(my $outfh, '>', $output); - print_fh($outfh, $output, encode('utf-8', $result)); - close($outfh); + $output->spew_utf8($result); return; } @@ -110,18 +109,20 @@ sub _spin_markdown { # navbar - Whether to add a navigation bar # path - Path to the POD file to convert # style - Style sheet to use +# $base - Base path of pointer file (for relative paths) # $output - Path to the output file # # Throws: Text exception on conversion failure sub _spin_pod { - my ($self, $data_ref, $output) = @_; - my $source = $data_ref->{path}; + my ($self, $data_ref, $base, $output) = @_; + my $source = path($data_ref->{path})->absolute($base); # Construct the Pod::Thread formatter object. #<<< my %options = ( contents => $data_ref->{options}{contents}, style => $data_ref->{style} // 'pod', + title => $data_ref->{title}, ); #<<< if (exists($data_ref->{options}{navbar})) { @@ -129,15 +130,12 @@ sub _spin_pod { } else { $options{navbar} = 1; } - if (exists($data_ref->{title})) { - $options{title} = $data_ref->{title}; - } my $podthread = Pod::Thread->new(%options); # Convert the POD to thread. my $data; $podthread->output_string(\$data); - $podthread->parse_file($source); + $podthread->parse_file("$source"); # Spin that page into HTML. $self->{thread}->spin_thread_output($data, $source, 'POD', $output); @@ -192,8 +190,8 @@ sub new { # Check if the result of a pointer file needs to be regenerated. # -# $pointer - Pointer file to process -# $output - Corresponding output path +# $pointer - Path to pointer file +# $output - Path to corresponding output file # # Returns: True if the output file does not exist or has a modification date # older than either the pointer file or the underlying source file, @@ -201,31 +199,35 @@ sub new { # Throws: YAML::XS exception on invalid pointer sub is_out_of_date { my ($self, $pointer, $output) = @_; + $pointer = path($pointer); my $data_ref = $self->load_yaml_file($pointer, 'pointer'); - if (!-e $data_ref->{path}) { - die "$pointer: path $data_ref->{path} does not exist\n"; + my $path = path($data_ref->{path})->absolute($pointer->parent()); + if (!$path->exists()) { + die "$pointer: path $data_ref->{path} ($path) does not exist\n"; } - return !is_newer($output, $pointer, $data_ref->{path}); + return !is_newer($output, $pointer, $path); } # Process a given pointer file. # -# $pointer - Pointer file to process -# $output - Corresponding output path +# $pointer - Path to pointer file to process +# $output - Path to corresponding output file # # Throws: YAML::XS exception on invalid pointer # Text exception for missing input file # Text exception on failure to convert the file sub spin_pointer { my ($self, $pointer, $output, $options_ref) = @_; + $pointer = path($pointer); + $output = path($output); my $data_ref = $self->load_yaml_file($pointer, 'pointer'); $data_ref->{options} //= {}; # Dispatch to the appropriate conversion function. if ($data_ref->{format} eq 'markdown') { - $self->_spin_markdown($data_ref, $output); + $self->_spin_markdown($data_ref, $pointer->parent(), $output); } elsif ($data_ref->{format} eq 'pod') { - $self->_spin_pod($data_ref, $output); + $self->_spin_pod($data_ref, $pointer->parent(), $output); } else { die "$pointer: unknown output format $data_ref->{format}\n"; } @@ -263,7 +265,7 @@ App::DocKnot::Spin::Pointer - Generate HTML from a pointer to an external file =head1 REQUIREMENTS Perl 5.24 or later and the modules File::ShareDir, Kwalify, List::SomeUtils, -Pod::Thread, and YAML::XS, all of which are available from CPAN. +Path::Tiny, Pod::Thread, and YAML::XS, all of which are available from CPAN. =head1 DESCRIPTION diff --git a/lib/App/DocKnot/Spin/RSS.pm b/lib/App/DocKnot/Spin/RSS.pm index 2a7460a..3f8fb70 100644 --- a/lib/App/DocKnot/Spin/RSS.pm +++ b/lib/App/DocKnot/Spin/RSS.pm @@ -9,7 +9,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Spin::RSS 6.00; +package App::DocKnot::Spin::RSS 6.01; use 5.024; use autodie; @@ -18,10 +18,9 @@ use warnings; use App::DocKnot; use App::DocKnot::Spin::Thread; use App::DocKnot::Util qw(print_checked print_fh); -use Cwd qw(getcwd); use Date::Language (); use Date::Parse qw(str2time); -use File::Basename qw(fileparse); +use Path::Tiny qw(path); use Perl6::Slurp qw(slurp); use POSIX qw(strftime); @@ -123,23 +122,30 @@ sub _relative_url { return ('../' x scalar(@base)) . $url; } -# Spin a file into HTML, changing directories to the directory of that file so -# that relative file references resolve correctly. +# Spin a file into HTML. # -# $file - Path to the file +# $file - Path::Tiny path to the file # # Returns: Rendered HTML as a list with one element per line sub _spin_file { my ($self, $file) = @_; - my $source = slurp($file); - my $cwd = getcwd(); - my (undef, $dir) = fileparse($file); - chdir($dir); - my $page = $self->{spin}->spin_thread($source); - chdir($cwd); + my $source = $file->slurp_utf8(); + my $page = $self->{spin}->spin_thread($source, $file); return map { "$_\n" } split(m{ \n }xms, $page); } +# Report an action to standard output. +# +# $action - String description of the action +# $output - Output file generated +# $base - Base path for all output +sub _report_action { + my ($self, $action, $output) = @_; + my $shortout = $output->relative($self->{base} // path()); + print_checked("$action .../$shortout\n"); + return; +} + ############################################################################## # Parsing ############################################################################## @@ -158,7 +164,7 @@ sub _read_rfc2822_file { # Parse the file. $key holds the last key seen, used to append # continuation values to the previous key. $current holds the current # block being parsed and @blocks all blocks seen so far. - open(my $fh, '<', $file); + my $fh = $file->openr_utf8(); while (defined(my $line = <$fh>)) { if ($line =~ m{ \A \s* \z }xms) { if ($key) { @@ -208,7 +214,7 @@ sub _read_rfc2822_file { # the changes into the provided array reference. Each element of the array # will be a hash with keys title, date, link, and description. # -# $file - File to read +# $file - Path::Tiny path to file to read # # Returns: List of reference to metadata hash and reference to a list of # hashes of changes @@ -273,7 +279,7 @@ sub _parse_changes { # Format a journal post into HTML for inclusion in an RSS feed. This depends # heavily on my personal layout for journal posts. # -# $file - Path to the journal post +# $file - Path::Tiny path to the journal post # # Returns: HTML suitable for including in an RSS feed sub _rss_journal { @@ -303,7 +309,7 @@ sub _rss_journal { # Format a review into HTML for inclusion in an RSS feed. This depends even # more heavily on my personal layout for review posts. # -# $file - Path to the review +# $file - Path::Tiny path to the review # # Returns: HTML suitable for inclusion in an RSS feed sub _rss_review { @@ -373,12 +379,13 @@ sub _rss_review { # time as <lastBuildDate>; it's not completely clear to me that this is # correct. # -# $fh - Output file handle -# $file - Name of the output file +# $file - Path::Tiny path to the output file +# $base - Base Path::Tiny path for input files # $metadata_ref - Hash of metadata for the RSS feed # $entries_ref - Array of entries in the RSS feed sub _rss_output { - my ($self, $fh, $file, $metadata_ref, $entries_ref) = @_; + my ($self, $file, $base, $metadata_ref, $entries_ref) = @_; + my $fh = $file->openw_utf8(); my $version = '1.25'; # Determine the current date and latest publication date of all of the @@ -405,7 +412,7 @@ sub _rss_output { <generator>DocKnot $App::DocKnot::VERSION</generator> EOC if ($metadata_ref->{'rss-base'}) { - my ($name) = fileparse($file); + my $name = $file->basename(); my $url = $metadata_ref->{'rss-base'} . $name; print_fh( $fh, @@ -427,9 +434,11 @@ EOC $description =~ s{ \A (\s*) }{$1<p>}xms; $description =~ s{ \n* \z }{</p>\n}xms; } elsif ($entry_ref->{journal}) { - $description = $self->_rss_journal($entry_ref->{journal}); + my $path = path($entry_ref->{journal})->absolute($base); + $description = $self->_rss_journal($path); } elsif ($entry_ref->{review}) { - $description = $self->_rss_review($entry_ref->{review}); + my $path = path($entry_ref->{review})->absolute($base); + $description = $self->_rss_review($path); } # Make all relative URLs absolute. @@ -464,6 +473,7 @@ EOC # Close the RSS structure. print_fh($fh, $file, " </channel>\n</rss>\n"); + close($fh); return; } @@ -473,12 +483,12 @@ EOC # Print out the thread version of the recent changes list. # -# $fh - File handle to which to output -# $file - Name of the file for error reporting +# $file - Path::Tiny output path # $metadata_ref - RSS feed metadata # $entries_ref - Entries sub _thread_output { - my ($self, $fh, $file, $metadata_ref, $entries_ref) = @_; + my ($self, $file, $metadata_ref, $entries_ref) = @_; + my $fh = $file->openw_utf8(); # Page prefix. if ($metadata_ref->{'thread-prefix'}) { @@ -520,6 +530,7 @@ sub _thread_output { # Print out the end of the page. print_fh($fh, $file, "\\signature\n"); + close($fh); return; } @@ -529,12 +540,12 @@ sub _thread_output { # Translate the thread of a journal entry for inclusion in an index page. # -# $file - Path to the journal entry +# $file - Path::Tiny to the journal entry # # Returns: Thread to include in the index page sub _index_journal { my ($self, $file, $url) = @_; - open(my $fh, '<', $file); + my $fh = $file->openr_utf8(); # Skip to the first \h1 and exclude it. while (defined(my $line = <$fh>)) { @@ -558,7 +569,7 @@ sub _index_journal { # Translate the thread of a book review for inclusion into an index page. # -# $file - Path to the book review +# $file - Path::Tiny to the book review # # Returns: Thread to include in the index page sub _index_review { @@ -571,7 +582,7 @@ sub _index_review { # Scan for the author information and save it. Handle the case where the # \header or \edited line is continued on the next line. - open(my $fh, '<', $file); + my $fh = $file->openr_utf8(); while (defined(my $line = <$fh>)) { if ($line =~ m{ \\ (?:header|edited) \s* \[ $char+ \] \s* \z }xms) { $line .= <$fh>; @@ -619,12 +630,13 @@ sub _index_review { # Print out the index version of the recent changes list. # -# $fh - File handle to which to output -# $file - Name of the file for error reporting +# $file - Path::Tiny path to the output file +# $base - Base Path::Tiny path for input files # $metadata_ref - RSS feed metadata # $entries_ref - Entries sub _index_output { - my ($self, $fh, $file, $metadata_ref, $entries_ref) = @_; + my ($self, $file, $base, $metadata_ref, $entries_ref) = @_; + my $fh = $file->openw_utf8(); # Output the prefix. if ($metadata_ref->{'index-prefix'}) { @@ -640,9 +652,11 @@ sub _index_output { # Get the text of the entry. my $text; if ($entry_ref->{journal}) { - $text = $self->_index_journal($entry_ref->{journal}); + my $path = path($entry_ref->{journal})->absolute($base); + $text = $self->_index_journal($path); } elsif ($entry_ref->{review}) { - $text = $self->_index_review($entry_ref->{review}); + my $path = path($entry_ref->{review})->absolute($base); + $text = $self->_index_review($path); } else { die "unknown entry type\n"; } @@ -674,6 +688,7 @@ sub _index_output { print_fh($fh, $file, $metadata_ref->{'index-suffix'}, "\n"); } print_fh($fh, $file, "\\signature\n"); + close($fh); return; } @@ -683,8 +698,8 @@ sub _index_output { # Create a new RSS generator object. # -# $args - Anonymous hash of arguments with the following keys: -# base - Base path for output files +# $args_ref - Anonymous hash of arguments with the following keys: +# base - Path::Tiny base path for output files # # Returns: Newly created object sub new { @@ -692,7 +707,7 @@ sub new { # Create and return the object. my $self = { - base => $args_ref->{base}, + base => defined($args_ref->{base}) ? path($args_ref->{base}) : undef, spin => App::DocKnot::Spin::Thread->new(), }; bless($self, $class); @@ -701,14 +716,13 @@ sub new { # Generate specified output files from an .rss input file. # -# $source - Path to the .rss file -# $base - Optional base path for output +# $source - Path::Tiny path to the .rss file +# $base - Optional Path::Tiny base path for output sub generate { my ($self, $source, $base) = @_; + $source = path($source); $base //= $self->{base}; - if ($base) { - $base =~ s{ /* \z}{/}xms; - } + $base = defined($base) ? path($base) : path(); # Read in the changes. my ($metadata_ref, $changes_ref) = $self->_parse_changes($source); @@ -722,14 +736,13 @@ sub generate { # Iterate through each specified output file. for my $output (@output) { my ($tags, $format, $file) = split(m{ : }xms, $output); - my $path = ($base && $file !~ m{ \A / }xms) ? "$base$file" : $file; - my $prettyfile = $path; - if ($prettyfile !~ m{ \A / }xms) { - $prettyfile = ".../$prettyfile"; + $file = path($file); + if ($file->is_relative()) { + $file = $file->absolute($base); } # If the output file is newer than the input file, do nothing. - next if (-e $path && -M $path <= -M $source); + next if ($file->exists() && -M "$file" <= -M "$source"); # Find all the changes of interest to this output file. my @entries; @@ -743,26 +756,21 @@ sub generate { # Write the output. if ($format eq 'thread') { - print_checked("Generating thread file $prettyfile\n"); - open(my $fh, '>', $path); - $self->_thread_output($fh, $path, $metadata_ref, \@entries); - close($fh); + $self->_report_action('Generating thread file', $file); + $self->_thread_output($file, $metadata_ref, \@entries); } elsif ($format eq 'rss') { if (scalar(@entries) > $metadata_ref->{recent}) { splice(@entries, $metadata_ref->{recent}); } - print_checked("Generating RSS file $prettyfile\n"); - open(my $fh, '>', $path); - $self->_rss_output($fh, $path, $metadata_ref, \@entries); - close($fh); + $self->_report_action('Generating RSS file', $file); + $self->_rss_output($file, $base, $metadata_ref, \@entries); } elsif ($format eq 'index') { if (scalar(@entries) > $metadata_ref->{recent}) { splice(@entries, $metadata_ref->{recent}); } - print_checked("Generating index file $prettyfile\n"); - open(my $fh, '>', $path); - $self->_index_output($fh, $path, $metadata_ref, \@entries); - close($fh); + $self->_report_action('Generating index file', $file); + my $index_base = $source->parent(); + $self->_index_output($file, $index_base, $metadata_ref, \@entries); } } return; @@ -792,9 +800,9 @@ App::DocKnot::Spin::RSS - Generate RSS and thread from a feed description file =head1 REQUIREMENTS -Perl 5.006 or later and the modules Date::Language, Date::Parse (both part of -the TimeDate distribution), List::SomeUtils, and Perl6::Slurp, both of which -are available from CPAN. +Perl 5.24 or later and the modules Date::Language, Date::Parse (both part of +the TimeDate distribution), List::SomeUtils, Path::Tiny, and Perl6::Slurp, +both of which are available from CPAN. =head1 DESCRIPTION @@ -834,7 +842,7 @@ with one or more of the following keys, all of which are optional: By default, App::DocKnot::Spin::RSS output files are relative to the current working directory. If the C<base> argument is given, output files will be relative to the value of C<base> instead. Output files specified as absolute -paths will not be affected. +paths will not be affected. C<base> may be a string or a Path::Tiny object. =back @@ -848,7 +856,8 @@ paths will not be affected. Parse the input file FILE and generate the output files that it specifies. BASE, if given, specifies the root directory for output files specified with -relative paths, and overrides any C<base> argument given to new(). +relative paths, and overrides any C<base> argument given to new(). Both FILE +and BASE may be strings or Path::Tiny objects. =back diff --git a/lib/App/DocKnot/Spin/Sitemap.pm b/lib/App/DocKnot/Spin/Sitemap.pm index 22fbdd5..3cbc97a 100644 --- a/lib/App/DocKnot/Spin/Sitemap.pm +++ b/lib/App/DocKnot/Spin/Sitemap.pm @@ -12,7 +12,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Spin::Sitemap 6.00; +package App::DocKnot::Spin::Sitemap 6.01; use 5.024; use autodie; @@ -226,7 +226,7 @@ sub new { # Return the <link> tags for a given output file, suitable for its <head> # section. # -# $path - Path to the output, relative to the top of the web site +# $path - URL path to the output with leading slash # # Returns: List of lines to add to the <head> section sub links { @@ -268,7 +268,7 @@ sub links { # Return the navigation bar for a given output file. # -# $path - Path to the output, relative to the top of the web site +# $path - URL path to the output with leading slash # # Returns: List of lines that create the navbar sub navbar { diff --git a/lib/App/DocKnot/Spin/Thread.pm b/lib/App/DocKnot/Spin/Thread.pm index bf58dc0..0d84bd7 100644 --- a/lib/App/DocKnot/Spin/Thread.pm +++ b/lib/App/DocKnot/Spin/Thread.pm @@ -9,7 +9,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Spin::Thread 6.00; +package App::DocKnot::Spin::Thread 6.01; use 5.024; use autodie; @@ -17,9 +17,6 @@ use warnings; use App::DocKnot; use App::DocKnot::Util qw(print_fh); -use Cwd qw(getcwd realpath); -use File::Basename qw(fileparse); -use File::Spec (); use Git::Repository (); use Image::Size qw(html_imgsize); use Path::Tiny qw(path); @@ -89,19 +86,37 @@ my %COMMANDS = ( # Input and output ############################################################################## +# Determine the path to a file relative to the current file being processed. +# If the current file being processed is standard input, the path is relative +# to the current working directory. +# +# $path - File path as a string +# +# Returns: Path::Tiny object holding the absolute path +sub _file_path { + my ($self, $file) = @_; + my $input_path = $self->{input}[-1][1]; + if (defined($input_path)) { + my $path = $input_path->sibling($file); + return $path->exists() ? $path->realpath() : $path; + } else { + return path($file); + } +} + # Read a file and check it for bad line endings. # -# $path - File path +# $path - Path::Tiny object # # Returns: Contents of the file sub _read_file { - my ($self, $fh, $path) = @_; - my $text = slurp($fh); + my ($self, $path) = @_; + my $text = $path->slurp_utf8(); # Check for broken line endings. if ("\n" !~ m{ \015 }xms && $text =~ m{ \015 }xms) { my $m = 'found CR characters; are your line endings correct?'; - $self->_warning($m); + $self->_warning($m, $path); } # Return the contents. @@ -159,15 +174,24 @@ sub _output { sub _fatal { my ($self, $problem) = @_; my (undef, $file, $lineno) = $self->{input}[-1]->@*; + $file //= q{-}; die "$file:$lineno: $problem\n"; } # Warn about a problem with the current file and line. # # $problem - Warning message to report +# $file - Optional path where the problem was seen, otherwise the current +# input file is used sub _warning { - my ($self, $problem) = @_; - my (undef, $file, $lineno) = $self->{input}[-1]->@*; + my ($self, $problem, $file) = @_; + my $lineno; + if (!defined($file)) { + (undef, $file, $lineno) = $self->{input}[-1]->@*; + $file //= q{-}; + } else { + $lineno = 0; + } warn "$file:$lineno: $problem\n"; return; } @@ -637,9 +661,9 @@ sub _parse { # needs to access. # # $thread - Thread to spin -# $in_path - Input file path if any, used for error reporting +# $in_path - Input file path as a Path::Tiny object, or undef # $out_fh - Output file handle to which to write the HTML -# $out_path - Optional output file path for error reporting and page links +# $out_path - Output file path as a Path::Tiny object, or undef # $input_type - Optional one-word description of input type sub _parse_document { my ($self, $thread, $in_path, $out_fh, $out_path, $input_type) = @_; @@ -653,7 +677,7 @@ sub _parse_document { $self->{input_type} = $input_type // 'thread'; $self->{macro} = {}; $self->{out_fh} = $out_fh; - $self->{out_path} = $out_path // q{-}; + $self->{out_path} = $out_path; $self->{rss} = []; $self->{space} = q{}; $self->{state} = ['BLOCK']; @@ -1019,9 +1043,9 @@ sub _cmd_heading { $style = $self->_parse($style); # Get the relative URL of the output page, used for sitemap information. - my $page = $self->{out_path}; - if ($self->{output}) { - $page =~ s{ \A \Q$self->{output}\E }{}xms; + my $page; + if (defined($self->{out_path}) && defined($self->{output})) { + $page = $self->{out_path}->relative($self->{output}); } # Build the page header. @@ -1054,8 +1078,8 @@ sub _cmd_heading { } # Add <link> tags based on the sitemap. - if ($self->{sitemap}) { - my @links = $self->{sitemap}->links($page); + if ($self->{sitemap} && defined($page)) { + my @links = $self->{sitemap}->links("/$page"); if (@links) { $output .= join(q{}, @links); } @@ -1066,17 +1090,15 @@ sub _cmd_heading { # Add some generator comments. my $date = strftime('%Y-%m-%d %T -0000', gmtime()); - my $from - = $self->{input}[-1][1] eq q{-} - ? q{} - : ' from ' . fileparse($self->{input}[-1][1]); + my $input_path = $self->{input}[-1][1]; + my $from = defined($input_path) ? ' from ' . $input_path->basename() : q{}; my $version = $App::DocKnot::VERSION; $output .= "<!-- Spun$from by DocKnot $version on $date -->\n"; # Add the <body> tag and the navbar (if we have a sitemap). $output .= "\n<body>\n"; - if ($self->{sitemap}) { - my @navbar = $self->{sitemap}->navbar($page); + if ($self->{sitemap} && defined($page)) { + my @navbar = $self->{sitemap}->navbar("/$page"); if (@navbar) { $output .= join(q{}, @navbar); } @@ -1096,7 +1118,8 @@ sub _cmd_image { $text = $self->_parse($text); # Determine the size attributes of the image if possible. - my $size = -e $image ? q{ } . lc(html_imgsize($image)) : q{}; + my $path = $self->_file_path($image); + my $size = $path->exists() ? q{ } . lc(html_imgsize("$path")) : q{}; # Generate the tag. my $output = qq{<img src="$image" alt="$text"$size}; @@ -1108,7 +1131,7 @@ sub _cmd_image { # not immediately, which may be a bit surprising. sub _cmd_include { my ($self, $file) = @_; - $file = realpath($self->_parse($file)); + $file = $self->_file_path($self->_parse($file)); # Read the thread, split it on paragraphs, and reverse it to make a stack. my $thread = $self->_read_file($file); @@ -1242,33 +1265,34 @@ sub _cmd_rss { # address block. sub _cmd_signature { my ($self) = @_; - my $source = $self->{input}[-1][1]; + my $input_path = $self->{input}[-1][1]; my $output = $self->_border_end(); # If we're spinning from standard input to standard output, don't add any # of the standard footer, just close the HTML tags. - if ($source eq q{-} && $self->{out_path} eq q{-}) { + if (!defined($input_path) && !defined($self->{out_path})) { $output .= "</body>\n</html>\n"; return (1, $output); } # Add the end-of-page navbar if we have sitemap information. if ($self->{sitemap} && $self->{output}) { - my $page = $self->{out_path}; - $page =~ s{ \A \Q$self->{output}\E }{}xms; - $output .= join(q{}, $self->{sitemap}->navbar($page)) . "\n"; + my $page = $self->{out_path}->relative($self->{output}); + $output .= join(q{}, $self->{sitemap}->navbar("/$page")) . "\n"; } # Figure out the modification dates. Use the Git repository if available. my $now = strftime('%Y-%m-%d', gmtime()); my $modified = $now; - if ($source ne q{-}) { - $modified = strftime('%Y-%m-%d', gmtime((stat($source))[9])); + if (defined($input_path)) { + $modified = strftime('%Y-%m-%d', gmtime($input_path->stat()->[9])); } if ($self->{repository} && $self->{source}) { - if (path($self->{source})->subsumes(path($source))) { + if (path($self->{source})->subsumes($input_path)) { my $repository = $self->{repository}; - $modified = $repository->run('log', '-1', '--format=%ct', $source); + $modified = $self->{repository}->run( + 'log', '-1', '--format=%ct', "$input_path", + ); if ($modified) { $modified = strftime('%Y-%m-%d', gmtime($modified)); } @@ -1295,10 +1319,13 @@ sub _cmd_signature { # enough and doesn't seem worth the trouble of another dependency. sub _cmd_size { my ($self, $file) = @_; - $file = $self->_parse($file); + $file = $self->_file_path($self->_parse($file)); # Get the size of the file. - my ($size) = (stat($file))[7]; + my $size; + if ($file->exists()) { + $size = $file->stat()->[7]; + } if (!defined($size)) { $self->_warning("cannot stat file $file: $!"); return (0, q{}); @@ -1394,6 +1421,10 @@ sub _cmd_version { # Returns: Newly created object sub new { my ($class, $args_ref) = @_; + my $output; + if (defined($args_ref->{output})) { + $output = path($args_ref->{output}); + } # Add a trailing slash to the partial URL for style sheets. my $style_url = $args_ref->{'style-url'} // q{}; @@ -1403,19 +1434,21 @@ sub new { # Use a Git::Repository object to get modification timestamps if a source # tree was specified and it appears to be a git repository. - my $source = $args_ref->{source}; - my $repository; - if (defined($source) && -d File::Spec->catdir($source, '.git')) { - $repository = Git::Repository->new(work_tree => $source); + my ($source, $repository); + if (defined($args_ref->{source})) { + $source = path($args_ref->{source}); + if ($source->child('.git')->is_dir()) { + $repository = Git::Repository->new(work_tree => "$source"); + } } # Create and return the object. #<<< my $self = { - output => $args_ref->{output}, + output => $output, repository => $repository, sitemap => $args_ref->{sitemap}, - source => $args_ref->{source}, + source => $source, style_url => $style_url, versions => $args_ref->{versions}, }; @@ -1427,14 +1460,15 @@ sub new { # Convert thread to HTML and return the output as a string. The working # directory still matters for file references in the thread. # -# $thread - Thread to spin +# $thread - Thread to spin +# $input - Optional input file path (for relative path and timestamps) # # Returns: Resulting HTML sub spin_thread { - my ($self, $thread) = @_; + my ($self, $thread, $input) = @_; my $result; open(my $out_fh, '>', \$result); - $self->_parse_document($thread, q{-}, $out_fh, q{-}); + $self->_parse_document($thread, $input, $out_fh, undef); close($out_fh); return $result; } @@ -1447,40 +1481,30 @@ sub spin_thread { # Raises: Text exception on processing error sub spin_thread_file { my ($self, $input, $output) = @_; - my $cwd = getcwd() or die "cannot get current directory: $!\n"; my $out_fh; my $thread; - # Read the input file. We do the work from the directory of the file to - # ensure that relative file references resolve properly. + # Read the input file. if (defined($input)) { - my $path = realpath($input) or die "cannot canonicalize $input: $!\n"; - $input = $path; - $thread = slurp($input); - my (undef, $input_dir) = fileparse($input); - chdir($input_dir); + $input = path($input)->realpath(); + $thread = $input->slurp_utf8(); } else { - $input = q{-}; $thread = slurp(\*STDIN); } # Open the output file. if (defined($output)) { - my $path = realpath($output) - or die "cannot canonicalize $output: $!\n"; - $output = $path; - open($out_fh, '>', $output); + $output = path($output)->absolute(); + $out_fh = $output->openw_utf8(); } else { - $output = q{-}; open($out_fh, '>&', 'STDOUT'); } # Do the work. $self->_parse_document($thread, $input, $out_fh, $output); - # Clean up and restore the working directory. + # Clean up. close($out_fh); - chdir($cwd); return; } @@ -1489,23 +1513,21 @@ sub spin_thread_file { # output from some other conversion process. # # $thread - Thread to spin -# $input - Original input file (for modification timestamps) +# $input - Original input file path (for relative path and timestamps) # $input_type - One-word description of input type for the page footer # $output - Output file # # Returns: Resulting HTML sub spin_thread_output { my ($self, $thread, $input, $input_type, $output) = @_; + $input = path($input); # Open the output file. my $out_fh; if (defined($output)) { - my $path = realpath($output) - or die "cannot canonicalize $output: $!\n"; - $output = $path; - open($out_fh, '>', $output); + $output = path($output)->absolute(); + $out_fh = $output->filehandle('>'); } else { - $output = q{-}; open($out_fh, '>&', 'STDOUT'); } @@ -1623,11 +1645,13 @@ data for the C<\release> and C<\version> commands. =over 4 -=item spin_thread(THREAD) +=item spin_thread(THREAD[, INPUT]) Convert the given thread to HTML, returning the result. When run via this API, App::DocKnot::Spin::Thread will not be able to obtain sitemap information even if a sitemap was provided and therefore will not add inter-page links. +INPUT, if given, is the full path to the original source file, used for +relative paths and modification time information. =item spin_thread_file([INPUT[, OUTPUT]]) @@ -1647,8 +1671,9 @@ not given, write the results to standard output. This is like spin_thread() but does use sitemap information and adds inter-page links. It should be used when the thread input is the result of an intermediate conversion step of a known input file. INPUT should be the full path to the original source file, -used for modification time information. TYPE should be set to a one-word -description of the format of the input file and is used for the page footer. +used for relative paths and modification time information. TYPE should be set +to a one-word description of the format of the input file and is used for the +page footer. =back diff --git a/lib/App/DocKnot/Spin/Versions.pm b/lib/App/DocKnot/Spin/Versions.pm index 679b368..1655ff0 100644 --- a/lib/App/DocKnot/Spin/Versions.pm +++ b/lib/App/DocKnot/Spin/Versions.pm @@ -12,12 +12,13 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Spin::Versions 6.00; +package App::DocKnot::Spin::Versions 6.01; use 5.024; use autodie; use warnings; +use Path::Tiny qw(path); use POSIX qw(mktime strftime); ############################################################################## @@ -56,18 +57,20 @@ sub _datetime_to_seconds { return mktime(@datetime); } -# Parse a .versions file and populate the App::DocKnot::Spin::Versions object. -# -# $path - Path to the .versions file +# Parse the .versions file and populate the App::DocKnot::Spin::Versions +# object. # # Raises: autodie exception on file read errors # Text exception on file parsing errors sub _read_data { - my ($self, $path) = @_; + my ($self) = @_; + $self->{depends} = {}; + $self->{versions} = {}; my $timestamp; - open(my $fh, '<', $path); - while (defined(my $line = <$fh>)) { + my $lineno = 0; + for my $line ($self->{path}->lines_utf8()) { + $lineno++; next if $line =~ m{ \A \s* \z }xms; next if $line =~ m{ \A \s* \# }xms; @@ -75,17 +78,17 @@ sub _read_data { my @depends; if ($line =~ m{ \A \s }xms) { if (!defined($timestamp)) { - die "continuation without previous entry in $path\n"; + die "continuation without previous entry in $self->{path}\n"; } @depends = split(qr{ \s+ }xms, $line); } else { my @line = split(qr{ \s+ }xms, $line); my ($package, $version, $date, $time, @files) = @line; if (!defined($time)) { - die "invalid line $. in $path\n"; + die "invalid line $lineno in $self->{path}\n"; } @depends = @files; - $timestamp = _datetime_to_seconds($date, $time, $path); + $timestamp = _datetime_to_seconds($date, $time, $self->{path}); $date = strftime('%Y-%m-%d', gmtime($timestamp)); $self->{versions}{$package} = [$version, $date]; } @@ -100,7 +103,6 @@ sub _read_data { } } } - close($fh); return; } @@ -119,14 +121,11 @@ sub new { my ($class, $path) = @_; # Create an empty object. - my $self = { - depends => {}, - versions => {}, - }; + my $self = { path => path($path) }; bless($self, $class); # Parse the file into the newly-created object. - $self->_read_data($path); + $self->_read_data(); # Return the populated object. return $self; @@ -134,13 +133,13 @@ sub new { # Return the timestamp of the latest release affecting a different page. # -# $file - File name that may be listed as an affected file for a release +# $file - File path that may be listed as an affected file for a release # # Returns: The timestamp in seconds since epoch of the latest release # affecting that file, or 0 if there are none sub latest_release { my ($self, $file) = @_; - return $self->{depends}{$file} // 0; + return $self->{depends}{"$file"} // 0; } # Return the release date for a given package. @@ -154,6 +153,50 @@ sub release_date { return defined($version) ? $version->[1] : undef; } +# Update the version and release date for a package. Add the change to Git if +# the .versions file is at the top of a Git repository. +# +# $package - Name of the package +# $version - New version +# $timestamp - New release date as seconds since epoch +# +# Throws: Text exception on failure +sub update_version { + my ($self, $package, $version, $timestamp) = @_; + my $date = strftime('%Y-%m-%d', localtime($timestamp)); + my $time = strftime('%H:%M:%S', localtime($timestamp)); + + # Edits the line for the package to replace the version and release date. + my $edit = sub { + my $line = $_; + my ($product, $old_version, $old_date, $old_time) + = split(q{ }, $line); + return if $product ne $package; + + # We're going to replace the old version with the new one, but we need + # to space-pad one or the other if they're not the same length. + my $version_string = $version; + while (length($old_version) > length($version_string)) { + $version_string .= q{ }; + } + while (length($old_version) < length($version_string)) { + $old_version .= q{ }; + } + + # Make the replacement. + $line =~ s{ \Q$old_version\E }{$version_string}xms; + $line =~ s{ \Q$old_date\E }{$date}xms; + $line =~ s{ \Q$old_time\E }{$time}xms; + $_ = $line; + }; + + # Apply that change to our versions file, and then re-read the contents to + # update the internal data structure. + $self->{path}->edit_utf8($edit); + $self->_read_data(); + return; +} + # Return the latest version for a given package. # # $package - Name of the package @@ -189,7 +232,7 @@ App::DocKnot::Spin::Versions - Parse package release information for spin =head1 REQUIREMENTS -Perl 5.24 or later. +Perl 5.24 or later and the Path::Tiny module, available from CPAN. =head1 DESCRIPTION @@ -271,6 +314,13 @@ PATH, or 0 if no releases affect that file. Return the release date of the latest release of PACKAGE (in UTC), or C<undef> if there is no release information for PACKAGE. +=item update_version(PACKAGE, VERSION, TIMESTAMP) + +Given a new VERSION and TIMESTAMP (in seconds since epoch) for a release of +PACKAGE, update the release information in the F<.versions> file for that +package accordingly. If the F<.versions> file is at the root of a Git +repository, this change will be staged with C<git add>. + =item version(PACKAGE) Return the version of the latest release of PACKAGE, or C<undef> if there is @@ -284,7 +334,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2004, 2021 Russ Allbery <rra@cpan.org> +Copyright 2004, 2021-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/App/DocKnot/Update.pm b/lib/App/DocKnot/Update.pm index 5c6a999..78b33de 100644 --- a/lib/App/DocKnot/Update.pm +++ b/lib/App/DocKnot/Update.pm @@ -9,7 +9,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Update 6.00; +package App::DocKnot::Update 6.01; use 5.024; use autodie; @@ -17,10 +17,10 @@ use parent qw(App::DocKnot); use warnings; use Carp qw(croak); -use File::Spec; use JSON::MaybeXS qw(JSON); use Kwalify qw(validate); -use Perl6::Slurp; +use Path::Iterator::Rule; +use Path::Tiny qw(path); use YAML::XS (); # The older JSON metadata format stored text snippets in separate files in the @@ -49,10 +49,10 @@ our @JSON_METADATA_FILES = qw( # # @path - The relative path of the file as a list of components # -# Returns: The absolute path in the metadata directory +# Returns: Path::Tiny for the metadata file sub _metadata_path { my ($self, @path) = @_; - return File::Spec->catdir($self->{metadata}, @path); + return path($self->{metadata}, @path); } # Internal helper routine to read a file from the package metadata directory @@ -65,7 +65,8 @@ sub _metadata_path { # Throws: slurp exception on failure to read the file sub _load_metadata { my ($self, @path) = @_; - return slurp('<:utf8', $self->_metadata_path(@path)); + my $path = $self->_metadata_path(@path); + return $path->slurp_utf8(); } # Like _load_metadata, but interprets the contents of the metadata file as @@ -158,6 +159,90 @@ sub _config_from_json { } ############################################################################## +# Spin helper methods +############################################################################## + +# Given an old-format *.rpod pointer file, read the master file name and any +# options. Return them in the structure used for *.spin pointer files. +# +# $path - Path::Tiny for the file to read +# +# Returns: Hash in the format of a *.spin pointer file +# Throws: Text exception if no master file is present in the pointer +# autodie exception if the pointer file could not be read +sub _read_rpod_pointer { + my ($self, $path) = @_; + + # Read the pointer file. + my ($master, $options, $style) = $path->lines_utf8(); + if (!$master) { + die "no master file specified in $path\n"; + } + chomp($master); + + # Put the results into the correct format. + my %results = (format => 'pod', path => $master); + if (defined($style)) { + chomp($style); + $results{style} = $style; + } + if (defined($options)) { + if ($options =~ m{ -c ( \s | \z ) }xms) { + $results{options} = { + contents => JSON::MaybeXS::true, + navbar => JSON::MaybeXS::false, + }; + } + if ($options =~ m{ -t \s+ (?: '(.*)' | ( [^\'] \S+ ) ) }xms) { + $results{title} = $1 || $2; + } + } + + # Return the parsed file. + return \%results; +} + +# Given its representation as a hash, write out a new-style *.spin file. +# +# $data_ref - Hash of data for the file +# $path - Path to output file +sub _write_spin_pointer { + my ($self, $data_ref, $path) = @_; + + # Generate the YAML output and strip off the leading document separator. + local $YAML::XS::Boolean = 'JSON::PP'; + my $yaml = YAML::XS::Dump($data_ref); + $yaml =~ s{ \A --- \n }{}xms; + + # Write the output. + $path->spew_utf8($yaml); + return; +} + +# Convert an *.rpod file to a *.spin file. Intended to be run via +# Path::Iterator::Rule. +# +# $rpod_path - Path to *.rpod file +# $repo - Optional Git::Repository object for input tree +sub _convert_rpod_pointer { + my ($self, $rpod_path, $repo) = @_; + + # Convert the file. + my $data_ref = $self->_read_rpod_pointer($rpod_path); + my $basename = $rpod_path->basename('.rpod'); + my $spin_path = $rpod_path->sibling($basename . '.spin'); + $self->_write_spin_pointer($data_ref, $spin_path); + + # If we have a Git repository, update Git. + if (defined($repo)) { + my $root = path($repo->work_tree()); + $repo->run('add', $spin_path->relative($root)->stringify()); + $repo->run('rm', $rpod_path->relative($root)->stringify()); + } + return; +} + +############################################################################## # Public Interface ############################################################################## @@ -172,17 +257,9 @@ sub _config_from_json { # Throws: Text exceptions on invalid metadata directory path sub new { my ($class, $args_ref) = @_; - - # Ensure we were given a valid metadata argument. - my $metadata = $args_ref->{metadata} // 'docs/metadata'; - if (!-d $metadata) { - croak("metadata path $metadata does not exist or is not a directory"); - } - - # Create and return the object. my $self = { - metadata => $metadata, - output => $args_ref->{output} // 'docs/docknot.yaml', + metadata => path($args_ref->{metadata} // 'docs/metadata'), + output => path($args_ref->{output} // 'docs/docknot.yaml'), }; bless($self, $class); return $self; @@ -197,6 +274,12 @@ sub new { sub update { my ($self) = @_; + # Ensure we were given a valid metadata argument. + if (!$self->{metadata}->is_dir()) { + my $metadata = $self->{metadata}; + croak("metadata path $metadata does not exist or is not a directory"); + } + # Tell YAML::XS that we'll be feeding it JSON::PP booleans. local $YAML::XS::Boolean = 'JSON::PP'; @@ -274,7 +357,29 @@ sub update { } # Write the new YAML package configuration. - YAML::XS::DumpFile($self->{output}, $data_ref); + YAML::XS::DumpFile($self->{output}->stringify(), $data_ref); + return; +} + +# Update an input tree for spin to the current format. +# +# $path - Optional path to the spin input tree, defaults to current directory +# +# Raises: Text exception on failure +sub update_spin { + my ($self, $path) = @_; + $path = defined($path) ? path($path) : path(q{.}); + my $repo; + if ($path->child('.git')->is_dir()) { + $repo = Git::Repository->new(work_tree => "$path"); + } + + # Convert all *.rpod files to *.spin files. + my $rule = Path::Iterator::Rule->new()->name(qr{ [.] rpod \z }xms); + my $iter = $rule->iter($path, { follow_symlinks => 0 }); + while (defined(my $file = $iter->())) { + $self->_convert_rpod_pointer(path($file), $repo); + } return; } @@ -290,23 +395,27 @@ Allbery DocKnot MERCHANTABILITY NONINFRINGEMENT sublicense CPAN XDG =head1 NAME -App::DocKnot::Update - Update DocKnot package configuration for new formats +App::DocKnot::Update - Update DocKnot input or package configuration =head1 SYNOPSIS use App::DocKnot::Update; - my $reader = App::DocKnot::Update->new( + + my $update = App::DocKnot::Update->new( { metadata => 'docs/metadata', output => 'docs/docknot.yaml', } ); - my $config = $reader->update(); + $update->update(); + + $update->update_spin('/path/to/spin/input'); =head1 REQUIREMENTS -Perl 5.24 or later and the modules File::BaseDir, File::ShareDir, JSON, -Perl6::Slurp, and YAML::XS, all of which are available from CPAN. +Perl 5.24 or later and the modules Git::Repository, File::BaseDir, +File::ShareDir, JSON::MaybeXS, Path::Iterator::Rule, Path::Tiny, Perl6::Slurp, +and YAML::XS, all of which are available from CPAN. =head1 DESCRIPTION @@ -348,6 +457,13 @@ F<docs/docknot.yaml> relative to the current directory. Load the legacy JSON metadata and write out the YAML equivalent. +=item update_spin([PATH]) + +Update the input tree for App::DocKnot::Spin to follow current expectations. +PATH is the path to the input tree, which defaults to the current directory +if not given. If the input tree is the working tree for a Git repository, +any changes are also registered with Git (but not committed). + =back =head1 AUTHOR @@ -356,7 +472,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2013-2021 Russ Allbery <rra@cpan.org> +Copyright 2013-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/App/DocKnot/Util.pm b/lib/App/DocKnot/Util.pm index 203d3d3..e5e4014 100644 --- a/lib/App/DocKnot/Util.pm +++ b/lib/App/DocKnot/Util.pm @@ -9,7 +9,7 @@ # Modules and declarations ############################################################################## -package App::DocKnot::Util 6.00; +package App::DocKnot::Util 6.01; use 5.024; use autodie; @@ -18,8 +18,9 @@ use warnings; use Carp qw(croak); use Exporter qw(import); use List::SomeUtils qw(all); +use Sort::Versions qw(versioncmp); -our @EXPORT_OK = qw(is_newer print_checked print_fh); +our @EXPORT_OK = qw(is_newer latest_tarball print_checked print_fh); ############################################################################## # Public interface @@ -39,6 +40,47 @@ sub is_newer { return all { $file_mtime >= $_ } @others_mtimes; } +# Find the files for a given package with the latest version and return them +# along with some associated metadata. +# +# $path - Path::Tiny path to directory +# $tarname - Name of the tarball before the version component +# +# Returns: Anonymous hash with the following keys: +# version - Latest version found +# date - Date (in seconds since epoch) of oldest file +# files - Array of files for that version +# or undef if no matching files were found +# Throws: Text exception on any error +sub latest_tarball { + my ($path, $tarname) = @_; + + # Collect the list of matching files and extract their version numbers. + return if !$path->is_dir(); + my $regex = qr{ \A \Q$tarname\E - ([\d.]+) [.] }xms; + my @files = map { $_->basename() } $path->children($regex); + my @versions = map { m{ $regex }xms ? [$1, $_] : () } @files; + return if !@versions; + + # Find the latest version and filter the list of files down to only that + # version. + @versions = reverse(sort { versioncmp($a->[0], $b->[0]) } @versions); + my $latest = $versions[0][0]; + @files = map { $_->[1] } grep { $_->[0] eq $latest } @versions; + + # Find the timestamps of those files. + my @times = sort(map { $path->child($_)->stat()->[9] } @files); + + # Return the results. + #<<< + return { + version => $latest, + date => $times[0], + files => \@files, + }; + #<<< +} + # print with error checking. autodie unfortunately can't help us because # print can't be prototyped and hence can't be overridden. # @@ -95,7 +137,8 @@ App::DocKnot::Util - Shared utility functions for other DocKnot modules =head1 REQUIREMENTS -Perl 5.24 or later and the List::SomeUtils module, available from CPAN. +Perl 5.24 or later and the modules List::SomeUtils and Sort::Versions, +available from CPAN. =head1 DESCRIPTION @@ -114,6 +157,28 @@ or equal to the last modified times of all SOURCE files, and otherwise returns a false value. Used primarily to determine if a given output file is up-to-date with respect to its source files. +=item latest_tarball(PATH, NAME) + +Returns data including a file list for the latest tarballs (by version number) +for a given software package NAME in the directory PATH. Versions are compared +using Sort::Versions. The return valid is a hash with the following keys: + +=over 4 + +=item date + +The timestamp of the oldest file for that version, in seconds since epoch. + +=item files + +The list of files found for that version. + +=item version + +The version number extracted from this set of files. + +=back + =item print_checked(ARG[, ARG ...]) The same as print (without a file handle argument), except that it throws a @@ -123,8 +188,8 @@ doesn't because print cannot be prototyped). =item print_fh(FH, NAME, DATA[, DATA ...]) Writes the concatenation of the DATA elements (interpreted as scalar strings) -to the file handle FH. NAME should be the name of the file open as FH, and is -used for error reporting. +to the file handle FH. NAME should be the name of (or Path::Tiny object for) +the file open as FH, and is used for error reporting. This is mostly equivalent to C<print {fh}> but throws a text exception in the event of a failure. @@ -137,7 +202,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 1999-2011, 2013, 2021 Russ Allbery <rra@cpan.org> +Copyright 1999-2011, 2013, 2021-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/share/schema/config.yaml b/share/schema/config.yaml index d49312c..d74e2cb 100644 --- a/share/schema/config.yaml +++ b/share/schema/config.yaml @@ -1,14 +1,18 @@ # Kwalify schema for DocKnot configuration file. # -# Copyright 2021 Russ Allbery <rra@cpan.org> +# Copyright 2021-2022 Russ Allbery <rra@cpan.org> # # SPDX-License-Identifier: MIT type: map mapping: + archivedir: + type: text distdir: type: text pandoc: type: text pgp_key: type: text + versions: + type: text diff --git a/t/cli/errors.t b/t/cli/errors.t index 2336977..c311d14 100755 --- a/t/cli/errors.t +++ b/t/cli/errors.t @@ -10,9 +10,11 @@ use 5.024; use autodie; use warnings; +use POSIX qw(LC_ALL setlocale); use Test::More tests => 11; # Isolate from the environment. +setlocale(LC_ALL, 'C'); local $ENV{XDG_CONFIG_HOME} = '/nonexistent'; local $ENV{XDG_CONFIG_DIRS} = '/nonexistent'; @@ -61,7 +63,13 @@ is_error($@, 'generate-all: too many arguments', 'Too many arguments'); # Trigger an error in a submodule to test error rewriting. eval { $docknot->run('generate', '-m', '/nonexistent', 'readme') }; -is_error($@, 'generate: metadata path /nonexistent does not exist'); +is_error( + $@, + ( + 'generate: can\'t open \'/nonexistent\' for input:' + . ' No such file or directory' + ), +); # Check for a missing required argument. eval { $docknot->run('dist') }; diff --git a/t/cli/spin.t b/t/cli/spin.t index ac7894f..2522eda 100755 --- a/t/cli/spin.t +++ b/t/cli/spin.t @@ -76,7 +76,11 @@ print_fh($fh, $pointer_path, "format: pod\n"); print_fh($fh, $pointer_path, "path: $pod_source\n"); close($fh); -# Spin a tree of files. +# Spin a tree of files. Do this from the temporary directory because 6.00 had +# a regression where docknot spin would fail if there were no package metadata +# even though it didn't use it. +my $cwd = getcwd(); +chdir($tempdir->dirname); $expected = File::Spec->catfile($datadir, 'output'); capture_stdout { $docknot->run( @@ -84,6 +88,7 @@ capture_stdout { $tempdir->dirname, ); }; +chdir($cwd); my $count = is_spin_output_tree($tempdir->dirname, $expected, 'spin'); # Spin a file with warnings. The specific warnings are checked in diff --git a/t/data/generate/docknot/output/thread b/t/data/generate/docknot/output/thread index 63e79c3..d1d89a0 100644 --- a/t/data/generate/docknot/output/thread +++ b/t/data/generate/docknot/output/thread @@ -117,9 +117,11 @@ The following additional Perl modules are required to use it: \bullet(packed)[JSON::MaybeXS] \bullet(packed)[Kwalify] \bullet(packed)[List::SomeUtils 0.07 or later] -\bullet(packed)[Path::Tiny] +\bullet(packed)[Path::Iterator::Rule] +\bullet(packed)[Path::Tiny 0.101 or later] \bullet(packed)[Perl6::Slurp] -\bullet(packed)[Pod::Thread 3.00 or later] +\bullet(packed)[Pod::Thread 3.01 or later] +\bullet(packed)[Sort::Versions] \bullet(packed)[Template (part of Template Toolkit)] \bullet(packed)[YAML::XS 0.81 or later] @@ -197,7 +199,7 @@ license: \block[ - Copyright 1999-2021 + Copyright 1999-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining diff --git a/t/data/perltidyrc b/t/data/perltidyrc index 431c311..dc3a2f7 100644 --- a/t/data/perltidyrc +++ b/t/data/perltidyrc @@ -6,7 +6,7 @@ # which can be found at <https://www.eyrie.org/~eagle/software/rra-c-util/>. # # Written by Russ Allbery <eagle@eyrie.org> -# Copyright 2021 Russ Allbery <eagle@eyrie.org> +# Copyright 2021-2022 Russ Allbery <eagle@eyrie.org> # Copyright 2012-2013 # The Board of Trustees of the Leland Stanford Junior University # @@ -22,8 +22,9 @@ -boc # do not re-break lists, since perltidy is awful at this -ce # cuddle braces around else -l=79 # usually use 78, but don't want 79-long lines reformatted +-nlop # disable vertical alignment of logical and ternary expressions -pt=2 # don't add extra whitespace around parentheses -sbt=2 # ...or square brackets --sfs # no space before semicolon in for (not that I use this form) +-nsfs # no space before semicolon in for (not that I use this form) -nvc # disable vertical alignment of = and similar symbols -xci # improve indentation of nested structures diff --git a/t/data/spin/input/journal/.rss b/t/data/spin/input/journal/.rss index 62f7c63..07c8e82 100644 --- a/t/data/spin/input/journal/.rss +++ b/t/data/spin/input/journal/.rss @@ -30,10 +30,10 @@ Index-Suffix: Date: 2011-08-13 00:09 Title: NPR Top 100 SFF meme Link: journal/2011-08/006.html -Journal: journal/2011-08/006.th +Journal: 2011-08/006.th Tags: debian Date: 2007-01-14 21:30 Title: Review: Fermat's Enigma Link: reviews/books/1-250-30112-2.html -Review: reviews/books/0-385-49362-2.th +Review: ../reviews/books/0-385-49362-2.th diff --git a/t/data/spin/update/input/module.rpod b/t/data/spin/update/input/module.rpod new file mode 100644 index 0000000..5f75697 --- /dev/null +++ b/t/data/spin/update/input/module.rpod @@ -0,0 +1 @@ +/path/Module.pm diff --git a/t/data/spin/update/input/readme.rpod b/t/data/spin/update/input/readme.rpod new file mode 100644 index 0000000..042274b --- /dev/null +++ b/t/data/spin/update/input/readme.rpod @@ -0,0 +1,2 @@ +/path/readme.pod +-c -t 'Basic Information' diff --git a/t/data/spin/update/input/script.rpod b/t/data/spin/update/input/script.rpod new file mode 100644 index 0000000..00f79c7 --- /dev/null +++ b/t/data/spin/update/input/script.rpod @@ -0,0 +1,3 @@ +/path/script + +/~eagle/styles/script.css diff --git a/t/data/spin/update/output/module.spin b/t/data/spin/update/output/module.spin new file mode 100644 index 0000000..31b97ce --- /dev/null +++ b/t/data/spin/update/output/module.spin @@ -0,0 +1,2 @@ +format: pod +path: /path/Module.pm diff --git a/t/data/spin/update/output/readme.spin b/t/data/spin/update/output/readme.spin new file mode 100644 index 0000000..c78a5cf --- /dev/null +++ b/t/data/spin/update/output/readme.spin @@ -0,0 +1,6 @@ +format: pod +options: + contents: true + navbar: false +path: /path/readme.pod +title: Basic Information diff --git a/t/data/spin/update/output/script.spin b/t/data/spin/update/output/script.spin new file mode 100644 index 0000000..5be3d64 --- /dev/null +++ b/t/data/spin/update/output/script.spin @@ -0,0 +1,3 @@ +format: pod +path: /path/script +style: /~eagle/styles/script.css diff --git a/t/dist/basic.t b/t/dist/basic.t index 6fcf2bc..dcc6b1b 100755 --- a/t/dist/basic.t +++ b/t/dist/basic.t @@ -20,6 +20,7 @@ use File::Temp; use Git::Repository; use IPC::Run qw(run); use IPC::System::Simple qw(capturex systemx); +use List::Util qw(first); use Test::More; @@ -52,9 +53,13 @@ $repo->run(add => '-A', q{.}); $repo->run(commit => '-q', '-m', 'Initial commit'); # Check whether we have all the necessary tools to run the test. +my @branches = $repo->run( + 'for-each-ref' => '--format=%(refname:short)', 'refs/heads/', +); +my $head = first { $_ eq 'main' || $_ eq 'master' } @branches; my $result; eval { - my $archive = $repo->command(archive => 'HEAD'); + my $archive = $repo->command(archive => '--prefix=foo/', $head); my $out; $result = run([qw(tar tf -)], '<', $archive->stdout, '>', \$out); $archive->close(); diff --git a/t/lib/Test/DocKnot/Spin.pm b/t/lib/Test/DocKnot/Spin.pm index c7c33b8..2c520a2 100644 --- a/t/lib/Test/DocKnot/Spin.pm +++ b/t/lib/Test/DocKnot/Spin.pm @@ -82,6 +82,10 @@ sub is_spin_output_tree { # File::Find on the output directory. my $check_output = sub { my $file = $_; + if ($file eq '.git') { + $File::Find::prune = 1; + return; + } return if -d $file; # Determine the relative path and mark it as seen. @@ -191,7 +195,7 @@ Russ Allbery <rra@cpan.org> =head1 COPYRIGHT AND LICENSE -Copyright 2021 Russ Allbery <rra@cpan.org> +Copyright 2021-2022 Russ Allbery <rra@cpan.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/t/lib/Test/RRA.pm b/t/lib/Test/RRA.pm index c432d0c..2e34bf5 100644 --- a/t/lib/Test/RRA.pm +++ b/t/lib/Test/RRA.pm @@ -52,7 +52,7 @@ BEGIN { # This version should match the corresponding rra-c-util release, but with # two digits for the minor version, including a leading zero if necessary, # so that it will sort properly. - $VERSION = '10.01'; + $VERSION = '10.02'; } # Compare a string to the contents of a file, similar to the standard is() diff --git a/t/lib/Test/RRA/Config.pm b/t/lib/Test/RRA/Config.pm index 75419ea..a2ba32c 100644 --- a/t/lib/Test/RRA/Config.pm +++ b/t/lib/Test/RRA/Config.pm @@ -32,7 +32,7 @@ BEGIN { # This version should match the corresponding rra-c-util release, but with # two digits for the minor version, including a leading zero if necessary, # so that it will sort properly. - $VERSION = '10.01'; + $VERSION = '10.02'; } # If C_TAP_BUILD or C_TAP_SOURCE are set in the environment, look for diff --git a/t/lib/Test/RRA/ModuleVersion.pm b/t/lib/Test/RRA/ModuleVersion.pm index d01c14b..aed52c1 100644 --- a/t/lib/Test/RRA/ModuleVersion.pm +++ b/t/lib/Test/RRA/ModuleVersion.pm @@ -29,7 +29,7 @@ BEGIN { # This version should match the corresponding rra-c-util release, but with # two digits for the minor version, including a leading zero if necessary, # so that it will sort properly. - $VERSION = '10.01'; + $VERSION = '10.02'; } # A regular expression matching the version string for a module using the @@ -103,9 +103,7 @@ sub _module_version { my ($file) = @_; open(my $data, q{<}, $file) or die "$0: cannot open $file: $!\n"; while (defined(my $line = <$data>)) { - if ( $line =~ $REGEX_VERSION_PACKAGE - || $line =~ $REGEX_VERSION_OLD) - { + if ($line =~ $REGEX_VERSION_PACKAGE || $line =~ $REGEX_VERSION_OLD) { my ($prefix, $version, $suffix) = ($1, $2, $3); close($data) or die "$0: error reading from $file: $!\n"; return $version; @@ -140,8 +138,8 @@ sub _update_module_version { or die "$0: cannot create $file.new: $!\n"; SCAN: while (defined(my $line = <$in>)) { - if ( $line =~ s{ $REGEX_VERSION_PACKAGE }{$1$version$3}xms - || $line =~ s{ $REGEX_VERSION_OLD }{$1$old_version$3}xms) + if ($line =~ s{ $REGEX_VERSION_PACKAGE }{$1$version$3}xms + || $line =~ s{ $REGEX_VERSION_OLD }{$1$old_version$3}xms) { print {$out} $line or die "$0: cannot write to $file.new: $!\n"; last SCAN; @@ -265,7 +263,7 @@ Russ Allbery <eagle@eyrie.org> =head1 COPYRIGHT AND LICENSE -Copyright 2016, 2018-2020 Russ Allbery <eagle@eyrie.org> +Copyright 2016, 2018-2020, 2022 Russ Allbery <eagle@eyrie.org> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/t/release/basic.t b/t/release/basic.t new file mode 100755 index 0000000..4379136 --- /dev/null +++ b/t/release/basic.t @@ -0,0 +1,116 @@ +#!/usr/bin/perl +# +# Tests for the App::DocKnot::Release module API. +# +# Copyright 2022 Russ Allbery <rra@cpan.org> +# +# SPDX-License-Identifier: MIT + +use 5.024; +use autodie; +use warnings; + +use lib 't/lib'; + +use Git::Repository (); +use Path::Tiny qw(path); + +use Test::More tests => 30; + +# Isolate from the environment. +local $ENV{XDG_CONFIG_HOME} = '/nonexistent'; +local $ENV{XDG_CONFIG_DIRS} = '/nonexistent'; + +# Load the module. +require_ok('App::DocKnot::Release'); + +# Construct a working area. +my $tempdir = Path::Tiny->tempdir(); +my $archive_path = $tempdir->child('archive'); +$archive_path->mkpath(); +my $old_path = $archive_path->child('ARCHIVE'); +my $dist_path = $tempdir->child('dist'); +$dist_path->mkpath(); + +# Make a release when there are no existing files. +my @extensions = qw(tar.gz tar.gz.asc tar.xz tar.xz.asc); +for my $ext (@extensions) { + $dist_path->child('Empty-1.9.' . $ext)->touch(); +} +my $metadata = path('t', 'data', 'dist', 'package', 'docs', 'docknot.yaml'); +my %options = ( + archivedir => $archive_path, + distdir => $dist_path, + metadata => $metadata, +); +my $release = App::DocKnot::Release->new(\%options); +$release->release(); + +# Check that the files were copied correctly and the symlinks were created. +for my $ext (@extensions) { + my $file = 'Empty-1.9.' . $ext; + ok($archive_path->child('devel', $file)->is_file(), "Copied $file"); + my $link = 'Empty.' . $ext; + is(readlink($archive_path->child('devel', $link)), $file, "Linked $link"); +} + +# Build a Git repository and a .versions file. +my $spin_path = $tempdir->child('spin'); +$spin_path->mkpath(); +my $versions_path = $spin_path->child('.versions'); +$versions_path->spew_utf8( + "empty 1.9 2022-01-01 16:00:00 software/empty/index.th\n", +); +Git::Repository->run('init', { cwd => "$spin_path", quiet => 1 }); +my $repo = Git::Repository->new(work_tree => "$spin_path"); +$repo->run(config => '--add', 'user.name', 'Test'); +$repo->run(config => '--add', 'user.email', 'test@example.com'); +$repo->run(add => '-A', q{.}); +$repo->run(commit => '-q', '-m', 'Initial commit'); + +# Construct a configuration file. +my $config_path = $tempdir->child('docknot', 'config.yaml'); +$config_path->parent()->mkpath(); +my @config = ( + "archivedir: $archive_path", + "distdir: $dist_path", + "versions: $versions_path", +); +$config_path->spew_utf8(join("\n", @config), "\n"); +local $ENV{XDG_CONFIG_HOME} = "$tempdir"; + +# Make another release, now relying on the global configuration. Add some +# other files to distdir to ensure they're ignored. +for my $ext (@extensions) { + $dist_path->child('Empty-1.10.' . $ext)->touch(); + $dist_path->child('foo-1.0.' . $ext)->touch(); +} +$release = App::DocKnot::Release->new({ metadata => $metadata }); +$release->release(); + +# Check that the files were copied correctly, the symlinks were created, and +# the old files were moved. Check that the old files were copied to the +# archive directory. +for my $ext (@extensions) { + my $file = 'Empty-1.10.' . $ext; + ok($archive_path->child('devel', $file)->is_file(), "Copied $file"); + my $old = 'Empty-1.9.' . $ext; + ok(!$archive_path->child('devel', $old)->is_file(), "Removed $old"); + ok( + $archive_path->child('ARCHIVE', 'Empty', $old)->is_file(), + "Archived $old", + ); + my $link = 'Empty.' . $ext; + is(readlink($archive_path->child('devel', $link)), $file, "Updated $link"); +} + +# Check that the version file was updated. +my @versions = split(q{ }, $versions_path->slurp_utf8()); +is($versions[0], 'empty', '.versions line'); +is($versions[1], '1.10', '...version updated'); +isnt(join(q{ }, @versions[2, 3]), '2022-01-01 16:00:00', '...date updated'); +is($versions[4], 'software/empty/index.th', '...dependency unchanged'); + +# Check that the change was staged. +my $status = $repo->run('status', '-s'); +is($status, ' M .versions', '.versions change was staged'); diff --git a/t/spin/errors.t b/t/spin/errors.t index d4cc565..c6e1ce6 100755 --- a/t/spin/errors.t +++ b/t/spin/errors.t @@ -43,5 +43,5 @@ my ($stdout, $stderr) = capture { # Simplify the file name, and then check against the expected output. $stderr =~ s{ ^ [^:]+/errors[.]th: }{errors.th:}xmsg; -$stderr =~ s{ (cannot [ ] stat [^:]+): .* }{$1\n}xms; +$stderr =~ s{ (cannot [ ] stat [ ] file [ ]) /[^:]+/([^/:]+) : .* }{$1$2\n}xms; is($stderr, $EXPECTED_ERRORS, 'errors are correct'); diff --git a/t/spin/sitemap.t b/t/spin/sitemap.t index 59f8fe3..b51d675 100755 --- a/t/spin/sitemap.t +++ b/t/spin/sitemap.t @@ -40,7 +40,7 @@ is_deeply(\@navbar, [], 'navbar for unknown page'); # exercised by the test of spinning a tree of files. @links = $sitemap->links('/faqs/soundness-inn.html'); my @expected = ( - q{ <link rel="next" href="soundness-cnews.html"} + q{ <link rel="next" href="soundness-cnews.html"} . qq{ title="Soundness for C News" />\n}, qq{ <link rel="up" href="./" title="FAQs and Documentation" />\n}, qq{ <link rel="top" href="../" />\n}, diff --git a/t/spin/tree.t b/t/spin/tree.t index d2daab1..700f961 100755 --- a/t/spin/tree.t +++ b/t/spin/tree.t @@ -35,35 +35,35 @@ Generating RSS file .../changes.rss Updating .../changes.rss Spinning .../changes.html Spinning .../index.html -Updating .../names.png -Spinning .../random.html Creating .../journal Generating index file .../journal/index.th Generating RSS file .../journal/index.rss Generating RSS file .../journal/debian.rss Generating RSS file .../journal/reviews.rss +Updating .../names.png +Spinning .../random.html +Creating .../reviews +Creating .../software +Creating .../usefor +Creating .../journal/2011-08 Updating .../journal/debian.rss Updating .../journal/index.rss Spinning .../journal/index.html Updating .../journal/reviews.rss -Creating .../journal/2011-08 -Spinning .../journal/2011-08/006.html -Creating .../reviews Creating .../reviews/books -Spinning .../reviews/books/0-385-49362-2.html -Creating .../software -Spinning .../software/index.html Creating .../software/docknot -Spinning .../software/docknot/index.html -Creating .../software/docknot/api -Converting .../software/docknot/api/app-docknot.html -Creating .../usefor -Spinning .../usefor/index.html +Spinning .../software/index.html Creating .../usefor/drafts +Spinning .../usefor/index.html +Spinning .../journal/2011-08/006.html +Spinning .../reviews/books/0-385-49362-2.html +Creating .../software/docknot/api +Spinning .../software/docknot/index.html Updating .../usefor/drafts/draft-ietf-usefor-message-id-01.txt Updating .../usefor/drafts/draft-ietf-usefor-posted-mailed-01.txt Updating .../usefor/drafts/draft-ietf-usefor-useage-01.txt Updating .../usefor/drafts/draft-lindsey-usefor-signed-01.txt +Converting .../software/docknot/api/app-docknot.html OUTPUT BEGIN { use_ok('App::DocKnot::Util', qw(print_fh)) } diff --git a/t/spin/versions.t b/t/spin/versions.t index 640e143..7f1e62b 100755 --- a/t/spin/versions.t +++ b/t/spin/versions.t @@ -19,8 +19,10 @@ use Test::More tests => 20; require_ok('App::DocKnot::Spin::Versions'); -# All dates in the sample data are in America/Los_Angeles. -local $ENV{TZ} = 'America/Los_Angeles'; +# All dates in the sample data are in America/Los_Angeles. Specify this in +# the POSIX format in the hope this will also work on systems without tzinfo +# installed. +local $ENV{TZ} = 'PST8PDT,M3.2.0,M11.1.0'; tzset(); # Parse the file. diff --git a/t/update/spin.t b/t/update/spin.t new file mode 100755 index 0000000..c692c80 --- /dev/null +++ b/t/update/spin.t @@ -0,0 +1,65 @@ +#!/usr/bin/perl +# +# Tests for the spin part of the App::DocKnot::Update module API. +# +# Copyright 2022 Russ Allbery <rra@cpan.org> +# +# SPDX-License-Identifier: MIT + +use 5.024; +use autodie; +use warnings; + +use lib 't/lib'; + +use File::Copy::Recursive qw(dircopy); +use Git::Repository (); +use Path::Tiny qw(path); +use Test::DocKnot::Spin qw(is_spin_output_tree); + +use Test::More; + +# Isolate from the environment. +local $ENV{XDG_CONFIG_HOME} = '/nonexistent'; +local $ENV{XDG_CONFIG_DIRS} = '/nonexistent'; + +# Load the module. +require_ok('App::DocKnot::Update'); + +# Construct the source tree. Copy t/data/spin/update/input into a fresh Git +# repository and commit it so that we can test the Git interaction. +my $input = path('t', 'data', 'spin', 'update', 'input'); +my $tempdir = Path::Tiny->tempdir(); +Git::Repository->run('init', { cwd => "$tempdir", quiet => 1 }); +dircopy($input, "$tempdir") + or die "$0: cannot copy $input to $tempdir: $!\n"; +my $repo = Git::Repository->new(work_tree => "$tempdir"); +$repo->run(config => '--add', 'user.name', 'Test'); +$repo->run(config => '--add', 'user.email', 'test@example.com'); +$repo->run(add => '-A', q{.}); +$repo->run(commit => '-q', '-m', 'Initial commit'); + +# Update the tree. +my $update = App::DocKnot::Update->new(); +$update->update_spin($tempdir); + +# Check the resulting output. +my $expected = path('t', 'data', 'spin', 'update', 'output'); +my $count = is_spin_output_tree("$tempdir", "$expected", 'Tree updated'); +my @changes = grep { m{ deleted | new [ ] file }xms } $repo->run('status'); +@changes = map { [split(q{ })] } sort(@changes); +is_deeply( + \@changes, + [ + ['deleted:', 'module.rpod'], + ['deleted:', 'readme.rpod'], + ['deleted:', 'script.rpod'], + ['new', 'file:', 'module.spin'], + ['new', 'file:', 'readme.spin'], + ['new', 'file:', 'script.spin'], + ], + 'Git operations', +); + +# Report the end of testing. +done_testing($count + 2); |