App-FuguWeb
view release on metacpan or search on metacpan
lib/App/FuguWeb/Page.pm view on Meta::CPAN
# ex:ts=8 sw=4:
# $OpenBSD$
#
# Copyright (c) 2026 Dick Olsson <hi@senzilla.io>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
use v5.36;
package App::FuguWeb::Page;
our $VERSION = '0.5.0';
use App::FuguWeb;
use Fugu::File;
# App::FuguWeb::Page - the shared chrome around one body fragment.
#
# Every source format is reduced to one HTML body fragment, and this
# module wraps it: the head, the header, the navigation, the fragment,
# and the footer. The layout is fixed. What a project decides is the
# site name, the language, the navigation, and the footer prose.
#
# The module builds the document with string operations and not with a
# substitution over a template. A title may therefore hold any
# character. The sed template that this replaced could not take a
# slash or an ampersand.
# The two separators that are not ASCII: an em dash between the page
# title and the site name, and a middle dot between navigation
# entries. No file in the namespace carries 'use utf8', and
# Fugu::File reads and writes bytes, so the constants are the UTF-8
# bytes themselves and reach the output unchanged.
use constant {
EM_DASH => "\xe2\x80\x94",
MIDDLE_DOT => "\xc2\xb7",
};
# The optional fragment that carries the footer prose. The text
# belongs to the project, not to the tool, so it is content in the
# source directory and not a setting.
use constant FOOTER_FRAGMENT => 'footer.body.html';
# App::FuguWeb::Page->new(%args):
# config => $config the site description (required)
# base => $prefix the step back to the site root
#
# A site is one flat directory, so the base is the empty string
# and every link of the chrome is a file name. The key directory
# is the one part that sits below the root. A page there passes
# '../', so the stylesheet, the banner and the navigation all
# name a file of the root.
sub new ( $class, %args )
{
my $config = $args{config};
die 'config parameter required'
unless defined $config;
return bless { config => $config, base => $args{base} // '' }, $class;
}
# $self->write($path, $title, $fragment):
# Write the whole page: the chrome around the fragment, as
# bytes. The fragment goes in unchanged: it is already HTML,
# from a renderer or from the project's own source directory.
# The method returns true on success, and undef with a message
# in the log otherwise.
sub write ( $self, $path, $title, $fragment )
{
return Fugu::File->write( $path, $self->document( $title, $fragment ) );
}
# $self->document($title, $fragment):
# The whole page as bytes. A caller that writes the file itself,
# or that holds the page beside other generated bytes, reads the
# document here and never repeats the chrome.
sub document ( $self, $title, $fragment )
{
return $self->_head($title) . ( $fragment // '' ) . $self->_foot;
}
# $self->_head($title):
# Everything before the fragment. Every value is escaped before
# the heredoc reads it, so nothing raw reaches the markup.
sub _head ( $self, $title )
{
my $config = $self->{config};
my $site = App::FuguWeb::escape_html( $config->site );
my $lang = App::FuguWeb::escape_attr( $config->lang );
my $entry = $self->_link( $config->entry );
my $full = App::FuguWeb::escape_html($title) . ' ' . EM_DASH . " $site";
my $sheet = $self->_link(App::FuguWeb::STYLESHEET);
return <<"HTML" . $self->_nav . "<hr>\n<main>\n";
<!DOCTYPE html>
<html lang="$lang">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$full</title>
<link rel="stylesheet" href="$sheet">
</head>
<body>
<header class="banner"><a href="$entry">$site</a></header>
HTML
}
# $self->_nav:
# The navigation. The separator joins the entries, so the row
# does not end in a dangling dot.
sub _nav ($self)
{
my @entries = $self->{config}->nav;
return "" unless @entries;
my @links = map {
'<a href="'
. $self->_link( $_->{href} ) . '">'
. App::FuguWeb::escape_html( $_->{label} ) . '</a>'
} @entries;
return
"<nav>\n"
. join( ' ' . MIDDLE_DOT . "\n", @links )
. "\n</nav>\n";
}
# $self->_link($href):
# One href of the chrome, escaped, with the step back to the
# site root in front of it.
#
# The step goes in front of a relative name only. An absolute
# URL, a root-absolute path and a fragment each name a place of
# their own. A step in front of one would name a page that the
# site does not hold.
sub _link ( $self, $href )
{
my $written = App::FuguWeb::escape_attr($href);
return $written if $href =~ m{\A(?:[A-Za-z][A-Za-z0-9.+-]*:|/|\#)};
return $self->{base} . $written;
}
# $self->_foot:
# Everything after the fragment. A project with no footer
# fragment gets no footer element and no rule before it: an
# empty footer is not chrome, it is a gap.
sub _foot ($self)
{
my $html = "</main>\n";
# A page below the site root carries no footer. The fragment
# is the prose of the project, and this class copies it in
# unchanged. A relative link of it would resolve against the
# directory of the page. The link would then name a file of
# the key directory, or no file at all, and the writer of the
# fragment named neither.
my $path = $self->{config}->source_path(FOOTER_FRAGMENT);
if ( -f $path && !length $self->{base} ) {
my $prose = Fugu::File->read($path) // '';
$html .= "<hr>\n<footer>\n$prose</footer>\n";
}
$html .= "</body>\n</html>\n";
return $html;
}
1;
( run in 1.189 second using v1.01-cache-2.11-cpan-364913b4093 )