samhuri.net/lib/pressa/views/layout.rb
2026-02-14 17:01:25 -08:00

347 lines
11 KiB
Ruby

require "phlex"
require "pressa/views/icons"
module Pressa
module Views
class Layout < Phlex::HTML
attr_reader :site,
:page_subtitle,
:page_description,
:page_type,
:canonical_url,
:page_scripts,
:page_styles,
:content
def initialize(
site:,
canonical_url:, page_subtitle: nil,
page_description: nil,
page_type: "website",
page_scripts: [],
page_styles: [],
content: nil
)
@site = site
@page_subtitle = page_subtitle
@page_description = page_description
@page_type = page_type
@canonical_url = canonical_url
@page_scripts = page_scripts
@page_styles = page_styles
@content = content
end
def view_template
doctype
html(lang: "en") do
comment { "meow" }
head do
meta(charset: "UTF-8")
title { full_title }
meta(name: "twitter:title", content: full_title)
meta(property: "og:title", content: full_title)
meta(name: "description", content: description)
meta(name: "twitter:description", content: description)
meta(property: "og:description", content: description)
meta(property: "og:site_name", content: site.title)
link(rel: "canonical", href: canonical_url)
meta(name: "twitter:url", content: canonical_url)
meta(property: "og:url", content: canonical_url)
meta(property: "og:image", content: og_image_url) if og_image_url
meta(property: "og:type", content: page_type)
meta(property: "article:author", content: site.author)
meta(name: "twitter:card", content: "summary")
link(
rel: "alternate",
href: site.url_for("/feed.xml"),
type: "application/rss+xml",
title: site.title
)
link(
rel: "alternate",
href: site.url_for("/feed.json"),
type: "application/json",
title: site.title
)
meta(name: "fediverse:creator", content: site.fediverse_creator) if site.fediverse_creator
link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
link(rel: "apple-touch-icon", href: site.url_for("/images/apple-touch-icon.png"))
link(rel: "mask-icon", color: "#aa0000", href: site.url_for("/images/safari-pinned-tab.svg"))
link(rel: "manifest", href: site.url_for("/images/manifest.json"))
meta(name: "msapplication-config", content: site.url_for("/images/browserconfig.xml"))
meta(name: "theme-color", content: "#121212")
meta(name: "viewport", content: "width=device-width, initial-scale=1.0, viewport-fit=cover")
link(rel: "dns-prefetch", href: "https://gist.github.com")
all_styles.each do |style|
link(rel: "stylesheet", type: "text/css", href: style_href(style.href))
end
end
body do
render_header
render(content) if content
render_footer
render_scripts
end
end
end
private
def description
page_description || site.description
end
def full_title
return site.title unless page_subtitle
"#{site.title}: #{page_subtitle}"
end
def og_image_url
site.image_url
end
def all_styles
site.styles + page_styles
end
def all_scripts
site.scripts + page_scripts
end
def render_header
header(class: "primary") do
div(class: "title") do
h1 do
a(href: site.url) { site.title }
end
h4 do
plain "By "
a(href: site.url_for("/about")) { site.author }
end
end
nav(class: "remote") do
ul do
remote_nav_links.each do |link|
li(class: remote_link_class(link)) do
attrs = {"aria-label": link.label, href: remote_link_href(link.href)}
attrs[:rel] = "me" if mastodon_link?(link)
a(**attrs) do
icon_markup = remote_link_icon_markup(link)
if icon_markup
raw(safe(icon_markup))
else
plain link.label
end
end
end
end
end
end
nav(class: "local") do
ul do
li { a(href: site.url_for("/about")) { "About" } }
li { a(href: site.url_for("/posts")) { "Archive" } }
li { a(href: site.url_for("/projects")) { "Projects" } }
end
end
div(class: "clearfix")
end
end
def render_footer
footer do
plain "© #{footer_years} "
a(href: site.url_for("/about")) { site.author }
end
end
def render_scripts
all_scripts.each do |scr|
attrs = {src: script_src(scr.src)}
attrs[:defer] = true if scr.defer
script(**attrs)
end
render_gemini_fallback_script
end
def render_gemini_fallback_script
# Inline so the behavior ships with the base HTML layout without needing
# separate asset management for one small handler.
script do
raw(safe(<<~JS))
(function () {
function isPlainLeftClick(e) {
return (
e.button === 0 &&
!e.defaultPrevented &&
!e.metaKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.altKey
);
}
function setupGeminiFallback() {
var links = document.querySelectorAll(
'header.primary nav.remote a[href^="gemini://"]'
);
if (!links || links.length === 0) return;
for (var i = 0; i < links.length; i++) {
(function (link) {
link.addEventListener("click", function (e) {
if (!isPlainLeftClick(e)) return;
e.preventDefault();
var geminiHref = link.getAttribute("href");
var fallbackHref = "https://geminiprotocol.net";
var done = false;
var fallbackTimer = null;
function cleanup() {
if (fallbackTimer) window.clearTimeout(fallbackTimer);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("blur", onBlur);
}
function markDone() {
done = true;
cleanup();
}
function onVisibilityChange() {
// If a handler opens and the browser backgrounded, consider it "successful".
if (document.visibilityState === "hidden") markDone();
}
function onPageHide() {
markDone();
}
function onBlur() {
// Some browsers blur the page when a protocol handler is invoked.
markDone();
}
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pagehide", onPageHide, { once: true });
window.addEventListener("blur", onBlur, { once: true });
// If we're still here shortly after attempting navigation, assume it failed.
fallbackTimer = window.setTimeout(function () {
if (done) return;
window.location.href = fallbackHref;
}, 900);
window.location.href = geminiHref;
});
})(links[i]);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupGeminiFallback);
} else {
setupGeminiFallback();
}
})();
JS
end
end
def script_src(src)
return src if src.start_with?("http://", "https://")
absolute_asset(src)
end
def style_href(href)
return href if href.start_with?("http://", "https://")
absolute_asset(href)
end
def absolute_asset(path)
normalized = path.start_with?("/") ? path : "/#{path}"
site.url_for(normalized)
end
def footer_years
current_year = Time.now.year
start_year = site.copyright_start_year || current_year
return current_year.to_s if start_year >= current_year
"#{start_year} - #{current_year}"
end
def html_remote_links
site.html_output_options&.remote_links || []
end
def remote_nav_links
html_remote_links
end
def remote_link_href(href)
return href if href.match?(/\A[a-z][a-z0-9+\-.]*:/i)
absolute_asset(href)
end
def remote_link_class(link)
slug = link.icon || link.label.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
"remote-link #{slug}"
end
def remote_link_icon_markup(link)
# Gemini doesn't have an obvious, widely-recognized protocol icon.
# Use a simple custom SVG mark so it aligns like the other SVG icons.
if link.icon == "gemini"
return <<~SVG.strip
<svg class="icon icon-gemini-protocol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path transform="translate(12 12) scale(0.84 1.04) translate(-12 -12)" d="M18,5.3C19.35,4.97 20.66,4.54 21.94,4L21.18,2.14C18.27,3.36 15.15,4 12,4C8.85,4 5.73,3.38 2.82,2.17L2.06,4C3.34,4.54 4.65,4.97 6,5.3V18.7C4.65,19.03 3.34,19.46 2.06,20L2.82,21.86C8.7,19.42 15.3,19.42 21.18,21.86L21.94,20C20.66,19.46 19.35,19.03 18,18.7V5.3M8,18.3V5.69C9.32,5.89 10.66,6 12,6C13.34,6 14.68,5.89 16,5.69V18.31C13.35,17.9 10.65,17.9 8,18.31V18.3Z"/>
</svg>
SVG
end
icon_renderer = remote_link_icon_renderer(link.icon)
return nil unless icon_renderer
Icons.public_send(icon_renderer)
end
def remote_link_icon_renderer(icon)
case icon
when "mastodon" then :mastodon
when "github" then :github
when "rss" then :rss
when "code" then :code
end
end
def mastodon_link?(link)
link.icon == "mastodon"
end
end
end
end