package view; use strict; use warnings; require 'doxy-link.pm'; use Dotiac::DTL qw/Template/; use Dotiac::DTL::Addon::markup; use ASF::Util qw/read_text_file shuffle/; use File::Temp qw/tempfile/; use LWP::Simple; use File::Spec; use Cwd 'realpath'; use Carp; # The top directory is the outermost directory that has an order file. use constant ORDER_FILE => 'order.txt'; # External variable file use constant VAR_FILE => 'vars.txt'; # The stem that marks an index file use constant INDEX_STEM => 'index'; # Name of the generated nav tree file use constant NAV_TREE_FILE => 'tree.nav'; # Root directory for all content. # This should be the relative path from the source control base directory # to the documentation root directory (the argument to --sourcebase with the # source control directory clipped off the front.) use constant SRC_DIR => 'content'; # Currently required suffixes for content files. our %SUFFIXES = ( mdtext => 1 ); # Page base if set our $PAGE_BASE; # Target directory if set our $TARGET_BASE; # Language to use when we can't find what we want. use constant DEFAULT_LANG => 'en'; # Split a content file in to its parts - ( stem, lang, format ) sub _SplitContentName { my ($name) = @_; my @zret = ('', '', ''); # default to empty strings. $_[0] =~ m!([^/]+)$!; # strip any leading directories. confess 'Invalid content file path "', $_[0], "\"\n" unless $name; @zret = ($1, $2, $3) if $name =~ m!^(.+)[.]([^.]+)[.]([^.]+)$!; return @zret; } # This is basically File::Spec::catdir except it makes a relative path # of just 'dir' if the base path is empty. sub _append_to_path { my ($path, $dir) = @_; return $path ? File::Spec->catdir($path, $dir) : $dir; } package view::Source; # A source / content file. This is a terminal in the documentation tree. # It has a map of language tags -> original file. # _LANGMAP -> Hash from language tag to source file data. # _FILE -> Local file name. # _LINK -> File name for HTML links. # _VARS -> Internal file variables. # # _STEM -> The stem for this source. # _PATH -> Path of the directory containing this source. # _VARS -> External variables for stem. sub new { my ($self, $path, $stem) = @_; $self = bless {}, $self if not ref $self; $self->_init; $self->{_PATH} = $path; $self->{_STEM} = $stem; return $self; } sub _init { my $self = shift; $self->{_LANGMAP} = {}; $self->{_VARS} = {}; return $self; } # accessors sub stem { return $_[0]->{_STEM}; } sub path { return $_[0]->{_PATH}; } # This is a convenience so we don't have to check for being a node. sub srcCount { return 0; } sub isTop { return 0; } sub parent { my ($self) = @_; return view::Node::GetLocation($self->{_PATH}); } sub top { my ($node) = @_; while (not $node->isTop) { $node = $node->parent; } return $node; } sub vars { my ($self, $vars) = @_; my $zret = $self->{_VARS}; $self->{_VARS} = $vars if scalar @_ > 1; return $zret; } sub setFileForLang { my ($self, $file, $lang) = @_; my $link = join('.', (view::_SplitContentName($file))[0,1], 'html'); $self->{_LANGMAP}{$lang} = { _FILE => $file, _LINK => $link }; } # Access the language specific data sub langToData { my ($self, $lang) = @_; my $zret = $self->{_LANGMAP}{$lang} || $self->{_LANGMAP}{view::DEFAULT_LANG()}; die "Stem '", $self->stem, "' in '", $self->{_PATH}, "' does not have language '$lang' or the default '", view::DEFAULT_LANG(), "'.\n" unless $zret; # Fault in the file at this point. if (not $zret->{_VARS}) { my %vars = %{$self->vars}; # copy external variables over. my $var_path = File::Spec->catfile($self->path, $zret->{_FILE}); view::read_text_file $var_path, \%vars; # add file internal vars. $zret->{_VARS} = \%vars; } return $zret; } # Get/Set the file for lang. This should be the local file name, not a path. sub langToFile { my ($self, $lang, $file) = @_; return $self->langToData($lang)->{_FILE}; } # Get the link name for @a lang. sub langToLink { my ($self, $lang) = @_; return $self->langToData($lang)->{_LINK}; } # Get an absolute (for the web server) path for a link to this source. sub absLinkPath { my ($self) = @_; my $path = $self->path; $path = substr($path, length(view::SRC_DIR()), length($path)); return $path; } # Get the variables (including content) for a language. sub langToVars { return $_[0]->langToData($_[1])->{_VARS}; } # Set the lang map / data directly. # Used by stem loading logic. sub copyLangMap { my ($self, $that) = @_; $self->{_LANGMAP} = $that->{_LANGMAP}; return $self; } sub langList { my ($self) = @_; return keys %{$self->{_LANGMAP}}; } # Compute a relative path from this source to another. sub pathTo { my ($self, $dest) = @_; my @spath = File::Spec->splitdir($self->path); my @dpath = File::Spec->splitdir($dest->path); my $rpath = ''; # strip off matching ancestors from the top. while (scalar @spath and scalar @dpath and $spath[0] eq $dpath[0]) { shift @spath; shift @dpath; } # Now work up the source path. while (scalar @spath) { $rpath = view::_append_to_path($rpath, '..'); pop @spath; } # Then back down the destination. while (scalar @dpath) { $rpath = view::_append_to_path($rpath, shift @dpath); } return $rpath; } package view::Node; our @ISA = ( 'view::Source' ); # Boolean constants use constant TRUE => 1; use constant FALSE => 0; use Carp; use Storable; # Map from directory paths to Nodes. our %Locations; # A non-terminal node in the documentation hierarchy. It must have some content # stored in either the 'index' file or a file with the same stem as the # containing directory. These are stored in a global hash of directory path # to Node instance. # _ORDER -> stem ordering. # _TOP_P -> is this a top level node? # _PATH -> Path to this directory, which is the containing directory for # the node content (which is always a file in this directory). sub _init { my $self = shift; $self->{_ORDER} = []; $self->{_TOP_P} = 0; $self->SUPER::_init; return $self; } sub isTop { my ($self, $flag) = @_; my $zret = $self->{_TOP_P}; $self->{_TOP_P} = $flag if scalar @_ > 1; return $zret; } # Because File::Spec::updir just adds a '..' to the end. sub ParentDir { my ($path) = @_; my @d = File::Spec->splitdir($path); my $p; # Bizarre but it handles emtpy directories. do { $p = pop @d; } while scalar @d and not $p; return File::Spec->catdir(@d); } sub parent { my ($self) = @_; return $self->isTop ? undef : GetLocation(ParentDir($self->path)) ; } # Add the src to the ordered list of stems. sub appendSrc { my ($self, $src) = @_; push @{$self->{_ORDER}}, $src; } # Find the source index for a stem. sub indexOfStem { my ($self, $stem) = @_; my $zret = 0; for my $src (@{$self->{_ORDER}}) { if (not ref $src and $src eq $stem) { # It's a not yet set directory - who should fault this in? return $zret; } elsif (ref $src and $stem eq $src->stem) { return $zret; } ++$zret; } return -1; } # Return number of sources. sub srcCount { return scalar @{$_[0]->{_ORDER}}; } # Return the source at the specified index sub srcAt { my ($self, $n) = @_; my $src = $self->{_ORDER}->[$n]; if (not ref $src) { $src = GetLocation(File::Spec->catdir($self->path, $src)); die "Internal error: Unknown cyclic / invalid directory reference.\n" unless $src; $self->{_ORDER}->[$n] = $src; } return $src; } sub LastOf { return $_[$#_]; } # Return a hash of stems in a directory. # stem -> name for directories. # stem -> array of file names for content files. # - Directory path. sub _LoadStems{ my ($dir) = @_; # Two things to count -- language based files and directories. my %stems; for (glob(File::Spec->catfile($dir, '*'))) { my $path = $_; next unless -r $path; my $name = (File::Spec->splitpath($path))[2]; next if '.' eq substr($name, 0, 1); if (-d $path) { die "Stem '$name' used for both files and directory in '$dir'.\n" if exists $stems{$name}; $stems{$name} = $name; } else { my ($stem, $lang, $format) = view::_SplitContentName($name); if ($stem) { die "'Invalid source format '$format' for '$stem.$lang' in '$dir'\n" unless $view::SUFFIXES{$format}; my $src = $stems{$stem}; if (not $src) { $src = $stems{$stem} = view::Source->new($dir, $stem); } elsif (not ref $src) { die "Stem '$stem' used for both files and directory in '$dir'.\n"; } $src->setFileForLang($name, $lang); } } } return \%stems; } # Given a full path to a directory, load it in to a Source or Node as appropriate. sub _LoadPath { my ($path) = @_; die "Must only load directories.\n" unless -d $path; $path =~ s!/+$!!; my $stem = LastOf(File::Spec->splitdir($path)); my $node = view::Node->new($path, $stem); # Scan directory for stems. my $stem_map = _LoadStems($path); # Check for order file. my $order_path= File::Spec->catfile($path, view::ORDER_FILE()); my @order; if (open my $fh, '<', $order_path) { local $/; # Grab the entire file @order = split(/\s+/, <$fh>); close $fh; @order = grep { $_ ne view::INDEX_STEM } @order; # index file never allowed. # If the index file exists, use that as node content. Otherwise fail. if (exists $stem_map->{view::INDEX_STEM()}) { $node->copyLangMap($stem_map->{view::INDEX_STEM()}); } elsif (scalar @order == 1) { # No index file, but the order list is a singleton so use that. $node->copyLangMap($stem_map->{$order[0]}); @order = (); } else { die "An order file was found but the required '", view::INDEX_STEM(), "' stem for directory content was not found in '$path'.\n"; } # Add the order specified stems to the node. my $fatal = FALSE; # so we can print all missing stems. for my $stem (@order) { my $src = $stem_map->{$stem}; if (not $src) { print "Stem '$stem' in order file but not in directory.\n"; $fatal = TRUE; } else { # If it's not an object, it's a source stem, locate if # available. Otherwise leave it to be faulted in later. $src = $Locations{$stem} if not ref $src and ref $Locations{$stem}; $node->appendSrc($src); } } die "Invalid order file in '$path'.\n" if $fatal; } else { # If there's no order file then only one stem is allowed and it # is treated as the content for this node. my @keys = keys %$stem_map; if (scalar @keys == 0) { die "No valid stems were found in '$path'.\n" } elsif (scalar @keys > 1) { die "Directories with multiple stems [", join(',', @keys), "] must have an '", view::ORDER_FILE(), "' ordering file at '$path'.\n" unless scalar @keys == 1; } my $src = $stem_map->{$keys[0]}; die "Directories cannot contain only a single nested directory at '$path'.\n" unless ref $src and $src->isa('view::Source'); $node->copyLangMap($src); # copy over the file mapping. } # See if we've got any variables defined. my $var_path = File::Spec->catfile($path, view::VAR_FILE()); if (-r $var_path) { my %vars; view::read_text_file $var_path, \%vars; delete $vars{content}; my $top_p = $vars{headers}{top} || $vars{headers}{Top}; $node->isTop($top_p and $top_p =~ m/true|yes|1/i); $node->vars(\%vars); } return $node; } # Get a location, return as a node. # This will recurse up the directory chain until it hits an already loaded # node or the top. # Args: # - Directory to retrieve. sub GetLocation { my ($dir) = @_; my $node = $Locations{$dir}; if (not $node) { $node = _LoadPath($dir); $Locations{$dir} = $node; if (not $node->isTop) { my $parent = $node->parent; # magic recursion happens here. my %locals = %{$node->vars}; # cache local vars. $node->vars(Storable::dclone($parent->vars)); # copy parent values. # overwrite parent values with locals. @{$node->vars->{keys %locals}} = values %locals; } } return $node; } # Get a source which is presumed to be a file (not a directory). # Return the Source object in scalar context # the Source object and the language tag in array context. # Args: # - Path to file. sub GetSource { my ($path) = @_; my ($vol, $dir, $file) = File::Spec->splitpath($path); my $node = GetLocation($dir); my $src = $node; my ($stem, $lang, $ext) = view::_SplitContentName($file); if ($node->langToFile($lang) ne $file) { my $idx = $node->indexOfStem($stem); $src = undef; if ($idx >= 0) { # If we don't find it in the index, that's tolerable # it means the file is there but it's not in order.txt # so just ignore it by return undef $src = $node->srcAt($idx); die "Failed to find source for '$path'.\n" unless $file eq $src->langToFile($lang); } } return wantarray ? ( $src , $lang ) : $src; } package view; # # BUILD CONSTRAINT: all views must return $content, $extension. # additional return values (as seen below) are optional. However, # careful use of symlinks and dependency management in path.pm can # resolve most issues with this constraint. # push @Dotiac::DTL::TEMPLATE_DIRS, "templates"; # This is most widely used view. It takes a # 'template' argument and a 'path' argument. # Assuming the path ends in foo.mdtext, any files # like foo.page/bar.mdtext will be parsed and # passed to the template in the "bar" (hash) # variable. sub single_narrative { my %args = @_; my $file = SRC_DIR . $args{path}; my $template = $args{template}; $args{path} =~ s/\.mdtext$/\.html/; $args{breadcrumbs} = breadcrumbs($args{path}); ## Set globals. # base URL for the page. Pass it on to the template as well. if ($PAGE_BASE = $ENV{ATS_CMS_PAGE_BASE}) { $args{page_base} = $PAGE_BASE; } # base output directory. $TARGET_BASE = $ENV{TARGET_BASE}; my ($src, $lang) = view::Node::GetSource($file); if (not $src) { warn "Ignoring '$file' because an order.txt was present without this stem.\n"; return ''; } $args{langs} = langs($src, $lang); $args{lang} = $lang; # copy over file local vars, including content. my $fvars = $src->langToVars($lang); @args{keys %$fvars} = values %$fvars; # Get the nav tree up to date. $args{nav_tree} = refresh_nav_tree($src, $lang); # Compute page local nav links. nav_links($src, \%args) if $args{headers}{navigation}; return $doxy::WV->process(Dotiac::DTL::Template($template)->render(\%args)), html => \%args; } # Similar to single_narrative but does no auto navigation. sub standalone_page { my %args = @_; my $file = SRC_DIR . $args{path}; my $template = $args{template}; $args{path} =~ s/\.mdtext$/\.html/; ## Set globals. # base URL for the page. Pass it on to the template as well. if ($PAGE_BASE = $ENV{ATS_CMS_PAGE_BASE}) { $args{page_base} = $PAGE_BASE; } # base output directory. $TARGET_BASE = $ENV{TARGET_BASE}; read_text_file $file, \%args; return Dotiac::DTL::Template($template)->render(\%args), html => \%args; } # Has the same behavior as the above for foo.page/bar.txt # files, parsing them into a bar variable for the template. # Otherwise presumes the template is the path. sub news_page { my %args = @_; my $template = "content$args{path}"; $args{breadcrumbs} = breadcrumbs($args{path}); my $page_path = $template; $page_path =~ s/\.[^.]+$/.page/; if (-d $page_path) { for my $f (grep -f, glob "$page_path/*.mdtext") { $f =~ m!/([^/]+)\.mdtext$! or die "Bad filename: $f\n"; $args{$1} = {}; read_text_file $f, $args{$1}; } } for ((fetch_doap_url_list())[0..2]) { push @{$args{projects}}, parse_doap($_); } return Dotiac::DTL::Template($template)->render(\%args), html => \%args; } sub sitemap { my %args = @_; my $template = "content$args{path}"; $args{breadcrumbs} .= breadcrumbs($args{path}); my $dir = $template; $dir =~ s!/[^/]+$!!; opendir my $dh, $dir or die "Can't opendir $dir: $!\n"; my %data; for (map "$dir/$_", grep $_ ne "." && $_ ne ".." && $_ ne ".svn", readdir $dh) { if (-f and /\.mdtext$/) { my $file = $_; $file =~ s/^content//; no warnings 'once'; for my $p (@path::patterns) { my ($re, $method, $args) = @$p; next unless $file =~ $re; my $s = view->can($method) or die "Can't locate method: $method\n"; my ($content, $ext, $vars) = $s->(path => $file, %$args); $file =~ s/\.mdtext$/.$ext/; $data{$file} = $vars; last; } } } my $content = ""; for (sort keys %data) { $content .= "- [$data{$_}->{headers}->{title}]($_)\n"; for my $hdr (grep /^#/, split "\n", $data{$_}->{content}) { $hdr =~ /^(#+)\s+([^#]+)?\s+\1\s+\{#([^}]+)\}$/ or next; my $level = length $1; $level *= 4; $content .= " " x $level; $content .= "- [$2]($_#$3)\n"; } } $args{content} = $content; return Dotiac::DTL::Template($template)->render(\%args), html => \%args; } sub exports { my %args = @_; my $template = "content$args{path}"; $args{breadcrumbs} = breadcrumbs($args{path}); my $page_path = $template; $page_path =~ s/\.[^.]+$/.page/; if (-d $page_path) { for my $f (grep -f, glob "$page_path/*.mdtext") { $f =~ m!/([^/]+)\.mdtext$! or die "Bad filename: $f\n"; $args{$1} = {}; read_text_file $f, $args{$1}; } $args{table} = `xsltproc $page_path/eccnmatrix.xsl $page_path/eccnmatrix.xml`; } return Dotiac::DTL::Template($template)->render(\%args), html => \%args; } sub parse_doap { my $url = shift; my $doap = get $url or die "Can't get $url: $!\n"; my ($fh, $filename) = tempfile("XXXXXX"); print $fh $doap; close $fh; my $result = eval `xsltproc lib/doap2perl.xsl $filename`; unlink $filename; return $result; } sub fetch_doap_url_list { my $xml = get "http://svn.apache.org/repos/asf/infrastructure/site-tools/trunk/projects/files.xml" or die "Can't get doap file list: $!\n"; my ($fh, $filename) = tempfile("XXXXXX"); print $fh $xml; close $fh; chomp(my @urls = grep /^http/, `xsltproc lib/list2urls.xsl $filename`); unlink $filename; shuffle \@urls; return @urls; } sub breadcrumbs { my @path = split m!/!, shift; pop @path; my @rv; my $relpath = ""; for (@path) { $relpath .= "$_/"; $_ ||= "Home"; push @rv, qq(\u$_); } return join " » ", @rv; } # -------------- # Generate a navigation subtree starting at this node. # Also need the language and relative path. sub nav_sub_tree { my ($target, $lang) = @_; my $text = '