Files
l5y 43a5724b7f web: add seo improvements (#771)
* web: add seo improvements

* web: address review comments

* web: address review comments
2026-04-29 10:33:33 +02:00

737 lines
25 KiB
Ruby

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
RSpec.describe PotatoMesh::App::Pages do
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-#{SecureRandom.hex(4)}") }
before do
FileUtils.mkdir_p(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
after do
FileUtils.rm_rf(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
# ── parse_page_filename ──────────────────────────────────────
describe ".parse_page_filename" do
it "parses a numeric-prefixed filename" do
entry = described_class.parse_page_filename("9-contact.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("9-contact")
expect(entry.slug).to eq("contact")
expect(entry.title).to eq("Contact")
end
it "parses a multi-word slug" do
entry = described_class.parse_page_filename("10-privacy-policy.md")
expect(entry.slug).to eq("privacy-policy")
expect(entry.title).to eq("Privacy Policy")
end
it "parses a filename without numeric prefix" do
entry = described_class.parse_page_filename("readme.md")
expect(entry).not_to be_nil
expect(entry.sort_key).to eq("readme")
expect(entry.slug).to eq("readme")
expect(entry.title).to eq("Readme")
end
it "parses a multi-digit prefix" do
entry = described_class.parse_page_filename("100-faq.md")
expect(entry.sort_key).to eq("100-faq")
expect(entry.slug).to eq("faq")
expect(entry.title).to eq("Faq")
end
it "rejects empty basename" do
expect(described_class.parse_page_filename(".md")).to be_nil
end
it "downcases uppercase slugs" do
entry = described_class.parse_page_filename("1-About.md")
expect(entry).not_to be_nil
expect(entry.slug).to eq("about")
end
it "rejects slugs with underscores" do
expect(described_class.parse_page_filename("1-my_page.md")).to be_nil
end
it "rejects slugs with path traversal" do
expect(described_class.parse_page_filename("../../etc.md")).to be_nil
end
it "rejects slugs starting with a hyphen" do
expect(described_class.parse_page_filename("1--bad.md")).to be_nil
end
it "rejects slugs ending with a hyphen" do
expect(described_class.parse_page_filename("bad-.md")).to be_nil
end
it "sets path to nil" do
entry = described_class.parse_page_filename("1-about.md")
expect(entry.path).to be_nil
end
end
# ── load_static_pages ────────────────────────────────────────
describe ".load_static_pages" do
it "returns an empty array when the directory does not exist" do
result = described_class.load_static_pages("/nonexistent/dir")
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory argument is nil" do
result = described_class.load_static_pages(nil)
expect(result).to eq([])
expect(result).to be_frozen
end
it "returns an empty array when the directory is empty" do
result = described_class.load_static_pages(pages_dir)
expect(result).to eq([])
end
it "discovers and sorts markdown files" do
File.write(File.join(pages_dir, "5-beta.md"), "# Beta")
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "9-gamma.md"), "# Gamma")
result = described_class.load_static_pages(pages_dir)
expect(result.map(&:slug)).to eq(%w[alpha beta gamma])
expect(result.map(&:sort_key)).to eq(%w[1-alpha 5-beta 9-gamma])
end
it "populates the path field" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result.first.path).to eq(File.join(pages_dir, "1-test.md"))
end
it "ignores non-md files" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
File.write(File.join(pages_dir, "notes.txt"), "text")
File.write(File.join(pages_dir, "image.png"), "binary")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("about")
end
it "skips files with invalid filenames" do
File.write(File.join(pages_dir, "1-good.md"), "# Good")
File.write(File.join(pages_dir, "1-bad_name.md"), "# Bad")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.slug).to eq("good")
end
it "deduplicates entries with the same slug keeping the first" do
File.write(File.join(pages_dir, "1-about.md"), "# First")
File.write(File.join(pages_dir, "2-about.md"), "# Second")
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(1)
expect(result.first.sort_key).to eq("1-about")
end
it "limits entries to MAX_PAGES" do
(1..55).each do |i|
File.write(File.join(pages_dir, "#{i}-page#{i}.md"), "# Page #{i}")
end
result = described_class.load_static_pages(pages_dir)
expect(result.length).to eq(PotatoMesh::App::Pages::MAX_PAGES)
end
it "returns a frozen array" do
File.write(File.join(pages_dir, "1-test.md"), "# Test")
result = described_class.load_static_pages(pages_dir)
expect(result).to be_frozen
end
end
# ── render_page_content ──────────────────────────────────────
describe ".render_page_content" do
it "renders markdown headings to HTML" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "# Hello World\n\nSome text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<h1")
expect(html).to include("Hello World")
expect(html).to include("<p>Some text.</p>")
end
it "renders links" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "[example](https://example.com)")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).to include("example")
end
it "renders fenced code blocks" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "```\ncode here\n```")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<code")
expect(html).to include("code here")
end
it "renders tables" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "| A | B |\n| - | - |\n| 1 | 2 |")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<table")
expect(html).to include("<td>")
end
it "does not pass through raw HTML script tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "<script>alert('xss')</script>\n\nSafe text.")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<script>")
end
it "does not pass through raw HTML iframe tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<iframe src="https://evil.com"></iframe>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("<iframe")
end
it "returns nil for a nil entry" do
expect(described_class.render_page_content(nil)).to be_nil
end
it "returns nil for a missing file" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-gone", slug: "gone", title: "Gone",
path: File.join(pages_dir, "missing.md"),
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when the file exceeds the size limit" do
path = File.join(pages_dir, "1-big.md")
File.write(path, "x" * (PotatoMesh::Config.max_page_file_bytes + 1))
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-big", slug: "big", title: "Big", path: path,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil when path is nil" do
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: nil,
)
expect(described_class.render_page_content(entry)).to be_nil
end
it "returns nil on a filesystem error" do
path = File.join(pages_dir, "1-err.md")
File.write(path, "# Error")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-err", slug: "err", title: "Err", path: path,
)
allow(File).to receive(:read).with(path, encoding: "utf-8").and_raise(Errno::EIO)
expect(described_class.render_page_content(entry)).to be_nil
end
it "strips event-handler attributes from allowed tags" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="https://example.com" onclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include('href="https://example.com"')
expect(html).not_to include("onclick")
end
it "strips nested event-handler bypass attempts" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="#" oonnclick="alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("onclick")
end
it "strips javascript: URIs from href attributes" do
path = File.join(pages_dir, "1-test.md")
File.write(path, '<a href="javascript:alert(1)">link</a>')
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).not_to include("javascript:")
end
it "preserves allowed HTML tags while stripping disallowed ones" do
path = File.join(pages_dir, "1-mixed.md")
File.write(path, "<strong>bold</strong> <script>bad</script>")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-mixed", slug: "mixed", title: "Mixed", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("<strong>")
expect(html).not_to include("<script")
end
end
# ── find_page_by_slug ───────────────────────────────────────
describe ".find_page_by_slug" do
it "finds a page by slug" do
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
File.write(File.join(pages_dir, "2-beta.md"), "# Beta")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
page = described_class.find_page_by_slug("beta")
expect(page).not_to be_nil
expect(page.slug).to eq("beta")
end
it "returns nil for an unknown slug" do
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
expect(described_class.find_page_by_slug("nonexistent")).to be_nil
end
end
# ── static_pages (caching) ──────────────────────────────────
describe ".static_pages" do
it "returns cached entries from the configured directory" do
File.write(File.join(pages_dir, "1-cached.md"), "# Cached")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
result = described_class.static_pages
expect(result.length).to eq(1)
expect(result.first.slug).to eq("cached")
end
it "clears the cache when clear_pages_cache! is called" do
File.write(File.join(pages_dir, "1-first.md"), "# First")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
first = described_class.static_pages
expect(first.length).to eq(1)
File.write(File.join(pages_dir, "2-second.md"), "# Second")
described_class.clear_pages_cache!
second = described_class.static_pages
expect(second.length).to eq(2)
end
end
# ── parse_frontmatter ───────────────────────────────────────
describe ".parse_frontmatter" do
it "returns an empty hash when there is no frontmatter" do
expect(described_class.parse_frontmatter("# Just markdown")).to eq({})
end
it "returns an empty hash for non-string input" do
expect(described_class.parse_frontmatter(nil)).to eq({})
end
it "extracts whitelisted keys" do
doc = "---\ntitle: Example\ndescription: A short summary.\nimage: https://e.com/p.png\nnoindex: true\n---\nbody"
result = described_class.parse_frontmatter(doc)
expect(result["title"]).to eq("Example")
expect(result["description"]).to eq("A short summary.")
expect(result["image"]).to eq("https://e.com/p.png")
expect(result["noindex"]).to be(true)
end
it "ignores keys outside the whitelist" do
doc = "---\ntitle: Example\nrandom: malicious\n---\nbody"
result = described_class.parse_frontmatter(doc)
expect(result).to have_key("title")
expect(result).not_to have_key("random")
end
it "treats malformed YAML as having no frontmatter" do
doc = "---\ntitle: : :\n---\nbody"
expect(described_class.parse_frontmatter(doc)).to eq({})
end
it "ignores frontmatter that does not parse into a Hash" do
doc = "---\n- one\n- two\n---\nbody"
expect(described_class.parse_frontmatter(doc)).to eq({})
end
it "coerces non-string scalars into strings for text fields" do
doc = "---\ntitle: 42\n---\nbody"
expect(described_class.parse_frontmatter(doc)["title"]).to eq("42")
end
it "drops blank string values" do
doc = "---\ntitle: ' '\n---\nbody"
expect(described_class.parse_frontmatter(doc)["title"]).to be_nil
end
it "accepts an https image URL" do
doc = "---\nimage: https://e.com/p.png\n---\nbody"
expect(described_class.parse_frontmatter(doc)["image"]).to eq("https://e.com/p.png")
end
it "rejects javascript: image URLs" do
doc = "---\nimage: 'javascript:alert(1)'\n---\nbody"
expect(described_class.parse_frontmatter(doc)["image"]).to be_nil
end
it "rejects data: image URLs" do
doc = "---\nimage: 'data:image/png;base64,iVBORw0KGgo='\n---\nbody"
expect(described_class.parse_frontmatter(doc)["image"]).to be_nil
end
it "rejects relative image paths" do
doc = "---\nimage: /assets/p.png\n---\nbody"
expect(described_class.parse_frontmatter(doc)["image"]).to be_nil
end
end
# ── strip_frontmatter ───────────────────────────────────────
describe ".strip_frontmatter" do
it "removes the leading frontmatter block" do
input = "---\ntitle: A\n---\n# Body\n"
expect(described_class.strip_frontmatter(input)).to eq("# Body\n")
end
it "passes through content without frontmatter unchanged" do
input = "# Body\n"
expect(described_class.strip_frontmatter(input)).to eq(input)
end
it "passes non-string input through" do
expect(described_class.strip_frontmatter(nil)).to be_nil
end
end
# ── truthy_frontmatter? ─────────────────────────────────────
describe ".truthy_frontmatter?" do
it "passes booleans through" do
expect(described_class.truthy_frontmatter?(true)).to be(true)
expect(described_class.truthy_frontmatter?(false)).to be(false)
end
it "matches common truthy strings" do
%w[true Yes 1 ON].each do |literal|
expect(described_class.truthy_frontmatter?(literal)).to be(true)
end
end
it "returns false for unknown values" do
expect(described_class.truthy_frontmatter?("nope")).to be(false)
end
end
# ── read_frontmatter_probe ──────────────────────────────────
describe ".read_frontmatter_probe" do
let(:dir) { File.join(SPEC_TMPDIR, "probe-#{SecureRandom.hex(4)}") }
before { FileUtils.mkdir_p(dir) }
after { FileUtils.rm_rf(dir) }
it "returns an empty string for a missing file" do
expect(described_class.read_frontmatter_probe(File.join(dir, "missing.md"))).to eq("")
end
it "reads the first FRONTMATTER_PROBE_BYTES bytes of the file" do
path = File.join(dir, "long.md")
File.write(path, "x" * (PotatoMesh::App::Pages::FRONTMATTER_PROBE_BYTES + 10))
probe = described_class.read_frontmatter_probe(path)
expect(probe.length).to eq(PotatoMesh::App::Pages::FRONTMATTER_PROBE_BYTES)
end
it "returns an empty string on filesystem errors" do
path = File.join(dir, "err.md")
File.write(path, "data")
allow(File).to receive(:open).with(path, "r:UTF-8").and_raise(Errno::EIO)
expect(described_class.read_frontmatter_probe(path)).to eq("")
end
end
# ── apply_frontmatter ───────────────────────────────────────
describe ".apply_frontmatter" do
it "returns the original entry when nothing is supplied" do
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
result = described_class.apply_frontmatter(base, {})
expect(result.title).to eq("X")
expect(result.description).to be_nil
expect(result.image).to be_nil
expect(result.noindex).to be(false)
end
it "returns nil when the base entry is nil" do
expect(described_class.apply_frontmatter(nil, {})).to be_nil
end
it "overrides title when frontmatter provides one" do
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
result = described_class.apply_frontmatter(base, "title" => "Custom")
expect(result.title).to eq("Custom")
end
it "keeps the filename-derived title when frontmatter omits one" do
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
result = described_class.apply_frontmatter(base, "description" => "Note")
expect(result.title).to eq("X")
end
it "captures noindex flag" do
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
result = described_class.apply_frontmatter(base, "noindex" => true)
expect(result.noindex).to be(true)
end
it "captures image and description fields" do
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
result = described_class.apply_frontmatter(
base,
"description" => "A summary.",
"image" => "https://e.com/p.png",
)
expect(result.description).to eq("A summary.")
expect(result.image).to eq("https://e.com/p.png")
end
end
# ── load_static_pages with frontmatter ──────────────────────
describe ".load_static_pages with frontmatter" do
it "applies frontmatter values during directory scans" do
File.write(
File.join(pages_dir, "1-about.md"),
"---\ntitle: About Page\ndescription: A summary.\nnoindex: true\n---\n# Body\n",
)
result = described_class.load_static_pages(pages_dir)
expect(result.first.title).to eq("About Page")
expect(result.first.description).to eq("A summary.")
expect(result.first.noindex).to be(true)
end
end
describe ".render_page_content with frontmatter" do
it "strips frontmatter before rendering" do
path = File.join(pages_dir, "1-test.md")
File.write(path, "---\ntitle: Doc\n---\n# Body Heading\n")
entry = PotatoMesh::App::Pages::PageEntry.new(
sort_key: "1-test", slug: "test", title: "Test", path: path,
)
html = described_class.render_page_content(entry)
expect(html).to include("Body Heading")
expect(html).not_to include("title: Doc")
end
end
# ── production_environment? ─────────────────────────────────
describe ".production_environment?" do
it "returns false in the test environment" do
expect(described_class.production_environment?).to be false
end
it "returns true when RACK_ENV is production" do
original = ENV["RACK_ENV"]
begin
ENV["RACK_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original
end
end
it "returns true when APP_ENV is production" do
original_rack = ENV["RACK_ENV"]
original_app = ENV["APP_ENV"]
begin
ENV["RACK_ENV"] = "test"
ENV["APP_ENV"] = "production"
expect(described_class.production_environment?).to be true
ensure
ENV["RACK_ENV"] = original_rack
ENV["APP_ENV"] = original_app
end
end
end
# ── Route integration ───────────────────────────────────────
let(:app) { Sinatra::Application }
describe "GET /pages/:slug" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-about.md"), "# About\n\nWelcome.")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "renders a valid page with 200" do
get "/pages/about"
expect(last_response).to be_ok
expect(last_response.body).to include("About")
expect(last_response.body).to include("Welcome.")
expect(last_response.body).to include("static-page")
end
it "renders the page within the site layout" do
get "/pages/about"
expect(last_response.body).to include("site-header")
expect(last_response.body).to include("site-nav")
end
it "marks the page as active in nav" do
get "/pages/about"
expect(last_response.body).to include('aria-current="page"')
end
it "returns 404 for an unknown slug" do
get "/pages/nonexistent"
expect(last_response.status).to eq(404)
end
it "rejects path traversal attempts" do
get "/pages/..%2F..%2Fetc"
expect(last_response.status).to be >= 400
end
it "returns 400 for an uppercase slug" do
get "/pages/ABOUT"
expect(last_response.status).to eq(400)
end
it "returns 400 for a slug with encoded special characters" do
get "/pages/a%3Cb"
expect(last_response.status).to eq(400)
end
it "returns 500 when page content cannot be rendered" do
File.write(File.join(pages_dir, "1-about.md"), "# About")
PotatoMesh::App::Pages.clear_pages_cache!
allow(PotatoMesh::App::Pages).to receive(:render_page_content).and_return(nil)
get "/pages/about"
expect(last_response.status).to eq(500)
end
it "includes nav links for static pages" do
File.write(File.join(pages_dir, "2-contact.md"), "# Contact")
PotatoMesh::App::Pages.clear_pages_cache!
get "/pages/about"
expect(last_response.body).to include('href="/pages/about"')
expect(last_response.body).to include('href="/pages/contact"')
end
end
describe "static page nav links on other pages" do
before do
FileUtils.mkdir_p(pages_dir)
File.write(File.join(pages_dir, "1-info.md"), "# Info")
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "shows page links in the dashboard nav" do
get "/"
expect(last_response.body).to include('href="/pages/info"')
expect(last_response.body).to include("Info")
end
end
end