#!/usr/bin/perl # Copyright © 2011 Jakub Wilk # © 2014-2024 Dmitry Shachnev # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. =head1 NAME dh_sphinxdoc - helps with packaging Sphinx-generated documentation =head1 SYNOPSIS dh_sphinxdoc [S>] [B<-X>I] [I...] =head1 DESCRIPTION B is a debhelper program that prepares Sphinx-generated documentation for inclusion in a Debian package. More specifically: =over 4 =item * It checks if all the files referenced by F exist. =item * It replaces known F<*.js> files with symlinks to F and generates B<${sphinxdoc:Depends}> substitution variable. =item * If the Sphinx RTD theme is used, it replaces known files from this theme with symlinks to F, and adds B to B<${sphinxdoc:Depends}>. =item * It provides a B<${sphinxdoc:Built-Using}> substitution variable, for tracking files which could not be symlinked. Examples of such files are F<*.js> and F<*.css> files that are generated from corresponding F<*.js_t> and F<*.css_t> templates, and can vary depending on the used theme options (for instance, F file is generated from F and is included in almost every Sphinx-generated documentation). Currently, this variable will contain B and, if the default theme is used, B, with their versions (other themes are not supported). =item * It removes the F<.doctrees> directory. =item * It removes the F<.buildinfo> file. =item * It removes the F file. =back Note that B does not build the documentation, it only performs the above operations when the documentation is already installed into the package build path. To build the docs, please use L command or B syntax. You can pass B<--with sphinxdoc> to L to make it automatically call B after B. =head1 OPTIONS =over 4 =item I By default, B scans your package looking for directories looking like they contain Sphinx-generated documentation. However, if you explicitly provide one or more directories, only they will be processed. If documentation is not found at I, an error is raised. =item B<-X>I, B<--exclude=>I Exclude files that contain I anywhere in their filename from being symlinked, removed or checked for existence. =back =head1 BUGS Symlinking translations.js is not supported. =cut use strict; use warnings; use Digest::MD5; use File::Basename; use File::Find; use Debian::Debhelper::Dh_Lib; use JSON; my %packaged_js = (); my @cruft_js = qw(websupport.js); sub md5($) { my ($filename) = @_; my $md5 = Digest::MD5->new; open(F, '<', $filename) or error("cannot open $filename"); $md5->addfile(*F); close(F); return $md5->digest; } sub load_packaged_js() { my %versions = (); my $root = 'debian/libjs-sphinxdoc'; # It's tempting to use # tmpdir('libjs-sphinxdoc') here, but it would break if the user passed # --tmpdir to the command. $root = '' unless -d $root; my $path = "$root/usr/share/javascript/sphinxdoc"; open(F, '<', "$path/index") or error("cannot open $path/index"); while () { chomp; next if /^(#|\s*$)/; my ($js, $minver) = split(/\s+/, $_, 2); unless (defined($minver)) { $js =~ m{^([0-9.]+)/} or error("syntax error in $path/index"); $minver = $1; } $versions{$js} = $minver; } close(F); find({ wanted => sub { my $js = $_; my ($jsbase, $jsname) = m{([0-9.]+/(\S+[.]js))$} or return; if ($jsbase =~ m{1\.0/(jquery|underscore)\.js}) { # These files are still shipped in libjs-sphinxdoc in order not to break # symlinks in previously built -doc packages. They will be dropped in some # future libjs-sphinxdoc version. After that, this check can be removed. return; } my $version = $versions{$jsbase}; defined($version) or error("$jsbase is not in the index; is it up-to-date?"); delete $versions{$jsbase}; my $md5; if (-l $js) { # Follow the symlink, but only if points *outside* our own directory. my $js_target = readlink($js); $js_target =~ m{^(/|\Q../../\E)} or return; unless ($js_target =~ m{^/}) { $js_target = "$js/../$js_target"; while ($js_target =~ s{[^./][^/]+/[.][.]/}{}) {}; } $md5 = md5($js_target); } else { $js =~ s{^\Q$root\E}{} unless -f $js; $md5 = md5($js); } $js =~ s{^\Q$root\E}{}; my $data = [$js, "libjs-sphinxdoc (>= $version)"]; $packaged_js{$md5} = $data; $packaged_js{$jsname} = $data; }, no_chdir => 1 }, $path); map { error("$path/$_ is missing") } keys(%versions); my %legacy_dependencies = ( "/usr/share/javascript/jquery/jquery.js" => "libjs-jquery (>= 3.6.0)", "/usr/share/javascript/jquery/jquery.min.js" => "libjs-jquery (>= 3.6.0)", "/usr/share/javascript/underscore/underscore.js" => "libjs-underscore (>= 1.3.1)", "/usr/share/javascript/underscore/underscore.min.js" => "libjs-underscore (>= 1.3.1)", ); while (my ($jsname, $dependency) = each %legacy_dependencies) { $packaged_js{md5($jsname)} = [$jsname, $dependency]; $packaged_js{basename($jsname)} = [$jsname, $dependency]; } } sub looks_like_sphinx_doc($) { my ($path) = @_; return 0 unless -f "$path/searchindex.js"; return 0 unless -f "$path/search.html"; return 1; } sub looks_like_sphinx_singlehtml_doc($) { my ($path) = @_; return 0 unless -d "$path/_static"; return 0 if -f "$path/searchindex.js"; # There should be exactly one HTML file in singlehtml build. my @html_files = glob("$path/*.html"); my @sphinx_html_files; foreach my $html_file (@html_files) { open(my $fh, '<', $html_file) or error("cannot open $html_file"); while (my $line = <$fh>) { if ($line =~ //) { push @sphinx_html_files, $html_file; last; } } } return 0 if @sphinx_html_files != 1; return $sphinx_html_files[0]; } sub sanity_check($$) { local $/; my ($path, $singlehtml_file) = @_; my $searchfn; my $index; if ($singlehtml_file) { # There is no search.html in singlehtml build, so we take the main HTML # file for sanity checking and retrieving JS files. $searchfn = $singlehtml_file; } else { my $indexfn = "$path/searchindex.js"; open(F, '<', $indexfn) or error("cannot open $indexfn"); $index = ; close(F); $index =~ m{^Search[.]setIndex[(](.*)[)]$} or error("$indexfn doesn't look like a Sphinx search index"); $index = decode_json($1); $searchfn = "$path/search.html"; } open(F, '<', $searchfn) or error("cannot open $searchfn"); my $search = ; close F; $search =~ s///g; # strip comments my %js = (); grep { s/[?#].*//; s/\s+$//; $js{$_} = 1 unless m/^[a-z][a-z0-9.+-]*:/i or excludefile("$path/$_"); } $search =~ m{}g; my $documentation_options; for my $line (split /^/, $search) { if ($line =~ "_static/documentation_options.js") { my $documentation_options_fn = "$path/_static/documentation_options.js"; open(my $fh, '<', $documentation_options_fn) or error("cannot open $documentation_options_fn"); $documentation_options = <$fh>; close $fh; } if ($line =~ "var DOCUMENTATION_OPTIONS =") { $documentation_options = $search; } } defined $documentation_options or error("DOCUMENTATION_OPTIONS not found"); my $loads_searchindex = $search =~ m{}; unless ($loads_searchindex) { # old style, used before Sphinx 2.0 $loads_searchindex = $search =~ m/\QjQuery(function() { Search.loadIndex("\E/; } my ($has_source) = $documentation_options =~ m{HAS_SOURCE:\s*(true|false)}; my ($sourcelink_suffix) = $documentation_options =~ m{SOURCELINK_SUFFIX:\s*'([^']*)'}; $sourcelink_suffix = ".txt" unless defined $sourcelink_suffix; my ($content_root) = $search =~ m{data-content_root="([^"]*)"}; unless (defined $content_root) { # Support the way zzzeeksphinx is setting it. ($content_root) = $search =~ m{document\.documentElement\.dataset\.content_root = '([^']*)';}; } unless (defined $content_root) { # We support fallback options using support_old_search_indexes.diff. # Drop this block when that patch gets removed. if ($documentation_options =~ /\QURL_ROOT: document.getElementById("documentation_options")\E/) { ($content_root) = $search =~ m{data-url_root="([^"]*)"}; } else { ($content_root) = $documentation_options =~ m{URL_ROOT:\s*'([^']*)'}; } } %js or error("$searchfn does not include any JavaScript code"); $singlehtml_file or $loads_searchindex or error("$searchfn does not load searchindex.js"); defined $has_source or error("DOCUMENTATION_OPTIONS does not define HAS_SOURCE"); defined $content_root or error("$searchfn top-level node does not have data-content_root attribute"); $has_source = $has_source eq 'true'; $content_root =~ m{^([a-z]+:/)?/} and error("content_root in $searchfn is not relative"); for my $js (keys(%js)) { -f "$path/$js" or -l "$path/$js" or error("$path/$js is missing"); } unless ($singlehtml_file) { my $pages = $index->{"filenames"}; for my $page (@$pages) { # Append sourcelink_suffix if the page name does not already end with it. (my $sourcepage = $page) =~ s/(? 0 and @target > 0 or die; if ($source[0] eq $target[0]) { # Make the symlink relative, as per Policy 10.5. while (@source > 0 and @target > 0 and $source[0] eq $target[0]) { shift @source; shift @target; } $target = ('../' x $#source) . join('/', @target); } else { # Keep the symlink absolute, as per Policy 10.5. $target = $orig_target; } doit('ln', '-sf', $target, $orig_source); } sub fix_symlinks($@) { my %deps = (); my ($path, @js) = @_; for my $js (@js) { my $id = ''; if (-l "$path/$js") { my $symlink_target = readlink("$path/$js"); $symlink_target =~ m{/sphinxdoc/} and next; $symlink_target =~ m{/javascript/\w+/(\w+)([.](min|lite|pack))?[.]js$} and $id = "$1.js"; } elsif (-f "$path/$js") { $id = md5("$path/$js"); } if (exists $packaged_js{$id}) { my ($target, $dependency) = @{$packaged_js{$id}}; ln_sf($target, "$path/$js"); $deps{$dependency} = 1; } else { unknown_javascript("$path/$js"); } } return keys %deps; } sub drop_cruft($) { my ($path) = @_; my $doctrees = "$path/.doctrees"; my $buildinfo = "$path/.buildinfo"; if (-d $doctrees and not excludefile($doctrees)) { doit('rm', '-rf', $doctrees); } if (-f $buildinfo and not excludefile($buildinfo)) { doit('rm', '-f', $buildinfo); } foreach my $js (@cruft_js) { my $js = "$path/_static/$js"; if (-f $js and not excludefile($js)) { doit('rm', '-f', $js) if -f $js; } } } sub process_rtd($) { my ($path) = @_; my $theme_is_rtd = 0; if (-d "$path/_static/js" and -f "$path/_static/js/theme.js") { if (open(F, '<', "$path/_static/js/theme.js")) { while (my $line = ) { if (index($line, "window.SphinxRtdTheme") != -1) { $theme_is_rtd = 1; last; } } close(F); } } my @deps; my $target_dir = "/usr/share/sphinx_rtd_theme/static"; if ($theme_is_rtd and -d $target_dir) { find({ wanted => sub { return if -d; my $filename = $_; substr($filename, 0, 1 + length $target_dir) = ""; return unless -f "$path/_static/$filename"; ln_sf($_, "$path/_static/$filename"); }, no_chdir => 1 }, $target_dir); my $rtd_theme_version = get_installed_package_version("sphinx-rtd-theme-common"); $rtd_theme_version =~ s/-[^-]+$//; # Remove the Debian version suffix push @deps, "sphinx-rtd-theme-common (>= $rtd_theme_version)"; } return @deps; } sub get_installed_package_version($) { my ($package_name) = @_; return `dpkg-query -W -f '\${Version}' $package_name 2>/dev/null`; } sub list_built_using($) { my ($path) = @_; my @built_using; my $sphinx_version = get_installed_package_version("sphinx-common"); push @built_using, "sphinx (= $sphinx_version)"; if (-d "$path/_static" and -f "$path/_static/alabaster.css") { my $alabaster_version = get_installed_package_version("python3-alabaster"); if ($alabaster_version) { push @built_using, "alabaster (= $alabaster_version)"; } } return @built_using; } sub fix_sphinx_doc($$) { my ($package, $path) = @_; my $is_html = looks_like_sphinx_doc($path); my $singlehtml_file = looks_like_sphinx_singlehtml_doc($path); return 0 if not ($is_html or $singlehtml_file); my @js = sanity_check($path, $singlehtml_file); my @rtd_deps = process_rtd($path); my @deps = fix_symlinks($path, @js); my @built_using = list_built_using($path); drop_cruft($path); map { addsubstvar($package, "sphinxdoc:Depends", $_) } @deps; map { addsubstvar($package, "sphinxdoc:Depends", $_) } @rtd_deps; map { addsubstvar($package, "sphinxdoc:Built-Using", $_) } @built_using; return 1; } init(); load_packaged_js(); my @paths = @ARGV; @paths = (undef) unless @paths; foreach my $path (@paths) { my $done = 0; my @matching_packages; foreach my $package (@{$dh{DOPACKAGES}}) { my $pkgpath = tmpdir($package); if (defined $path) { next if -l $path; $pkgpath .= "/$path"; next unless -d $pkgpath; push @matching_packages, $package; $done += fix_sphinx_doc($package, $pkgpath); } else { $pkgpath .= '/usr/share/doc/'; next unless -d $pkgpath; find({ wanted => sub { return unless -d; return if -l; return if excludefile($_); $done += fix_sphinx_doc($package, $_); }, no_chdir => 1 }, $pkgpath); } } if ($done == 0) { if (defined $path) { if (!@matching_packages) { error("Path $path not found in any built package\n(searched in: @{$dh{DOPACKAGES}})"); } error("Sphinx documentation not found at $path\n(path found in packages: @matching_packages)"); } else { warning("Sphinx documentation not found"); } } } =head1 SEE ALSO L, L. This program is meant to be used together with debhelper. =head1 AUTHOR Jakub Wilk =cut # vim:ts=4 sw=4 et