Compare commits

...

6 Commits

Author SHA1 Message Date
l5y 09d9e7be13 chore: bump version to 0.6.3 (#779)
* chore: bump version to 0.6.3

* chore: bump version to 0.6.3
2026-04-29 22:06:29 +02:00
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
l5y c4dd825d72 matrix: fix matrix preset labels (#770)
* matrix: fix matrix preset labels

* matrix: address review comments

* matrix: address review comments
2026-04-29 07:49:31 +02:00
l5y ee98efc120 web: rework map spider-net feature (#769)
* web: rework map spider-net feature

* web: address review comments

* web: address review comments
2026-04-29 07:06:18 +02:00
dependabot[bot] 521c2f2972 build(deps): bump openssl from 0.10.75 to 0.10.78 in /matrix (#766)
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.75 to 0.10.78.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.75...openssl-v0.10.78)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.78
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 19:15:29 +02:00
dependabot[bot] f8b02b9f24 build(deps): bump rustls-webpki from 0.103.10 to 0.103.13 in /matrix (#764)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.10 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.13)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:57:35 +02:00
36 changed files with 4846 additions and 117 deletions
+27 -5
View File
@@ -105,10 +105,32 @@ The web app can be configured with environment variables (defaults shown):
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor will ignore when forwarding packets. |
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
| `OG_IMAGE_URL` | _unset_ | Optional absolute URL for the social preview image. Must use an `http://` or `https://` scheme; values with other schemes are ignored. Most social platforms (Facebook, LinkedIn, Slack, iMessage) require **HTTPS** to render the card. When set, replaces the runtime-generated `/og-image.png` so deployments without Chromium (or with size-conscious images) can point at a CDN. |
| `OG_IMAGE_TTL_SECONDS` | `3600` | Cache lifetime for the runtime-generated dashboard screenshot served at `/og-image.png`. |
| `FERRUM_BROWSER_PATH` | `/usr/bin/chromium` (Docker) | Path to the headless Chromium binary used by the Open Graph preview generator. |
The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled
logo for Open Graph and Twitter cards.
preview tags from these existing configuration values. `/robots.txt` and
`/sitemap.xml` are generated automatically and respect `PRIVATE`/`FEDERATION`
toggles; markdown files in `pages/` may declare optional YAML frontmatter
(`title`, `description`, `image`, `noindex`) for per-page overrides. The
`image:` frontmatter must be an absolute `http(s)://` URL; other schemes are
silently dropped to keep operators from accidentally leaking `data:` or
`javascript:` URIs into Open Graph tags.
If `INSTANCE_DOMAIN` is unset in production the app emits a one-time `WARN`
at startup; canonical URLs and sitemap entries fall back to the inbound
`Host` header, which can be cache-poisoned by a misconfigured proxy. Set
`INSTANCE_DOMAIN` to your public hostname to silence the warning.
#### Open Graph preview image
The web container ships with Chromium so `/og-image.png` returns a fresh
screenshot of the live dashboard, cached on disk for `OG_IMAGE_TTL_SECONDS`.
Operators on size-constrained hosts can build a slim image by passing
`--build-arg WITH_OG_IMAGE=0` to `docker build`; the route then falls back to
the bundled `public/og-image-default.png`. Set `OG_IMAGE_URL` to an external
PNG/JPG (e.g. on a CDN) to avoid runtime capture entirely.
Example:
@@ -300,9 +322,9 @@ docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-arm64:latest
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-armv7:latest
# version-pinned examples
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.6.2
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.6.2
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.6.2
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.6.3
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.6.3
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.6.3
```
Note: `latest` is only published for non-prerelease versions. Pre-release tags
+2 -2
View File
@@ -15,11 +15,11 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.6.2</string>
<string>0.6.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.6.2</string>
<string>0.6.3</string>
<key>MinimumOSVersion</key>
<string>14.0</string>
</dict>
+1 -1
View File
@@ -1,7 +1,7 @@
name: potato_mesh_reader
description: Meshtastic Reader — read-only view for PotatoMesh messages.
publish_to: "none"
version: 0.6.2
version: 0.6.3
environment:
sdk: ">=3.4.0 <4.0.0"
+1 -1
View File
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
message information before forwarding it to the accompanying web application.
"""
VERSION = "0.6.2"
VERSION = "0.6.3"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION
+7 -7
View File
@@ -878,9 +878,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [
"bitflags",
"cfg-if",
@@ -910,9 +910,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [
"cc",
"libc",
@@ -969,7 +969,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potatomesh-matrix-bridge"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"anyhow",
"axum",
@@ -1255,9 +1255,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
+1 -1
View File
@@ -14,7 +14,7 @@
[package]
name = "potatomesh-matrix-bridge"
version = "0.6.2"
version = "0.6.3"
edition = "2021"
[dependencies]
+45 -29
View File
@@ -17,6 +17,7 @@ mod config;
mod matrix;
mod matrix_server;
mod potatomesh;
mod preset;
use std::{fs, net::SocketAddr, path::Path};
@@ -255,8 +256,17 @@ async fn handle_message(
let display_name = display_name_for_node(&node);
matrix.set_display_name(&user_id, &display_name).await?;
// Format the bridged message
let preset_short = modem_preset_short(&msg.modem_preset);
// Format the bridged message. `lora_freq` is `u32`, so 0 stands in for
// "unknown" — collapse that to `None` to match the JS pipeline (which
// runs `normalizeFrequency` and discards 0/non-finite before reaching
// the preset lookup).
let freq_mhz = if msg.lora_freq > 0 {
Some(msg.lora_freq as f64)
} else {
None
};
let abbr = preset::abbreviate_preset(&msg.modem_preset, freq_mhz);
let preset_short = preset::normalize_preset_slot(abbr.as_deref());
let tag = protocol_tag(msg.protocol.as_deref());
let prefix = format!(
"{tag}[{freq}][{preset_short}][{channel}]",
@@ -290,19 +300,6 @@ fn protocol_tag(protocol: Option<&str>) -> &'static str {
}
}
/// Build a compact modem preset label like "LF" for "LongFast".
fn modem_preset_short(preset: &str) -> String {
let letters: String = preset
.chars()
.filter(|ch| ch.is_ascii_uppercase())
.collect();
if letters.is_empty() {
preset.chars().take(2).collect()
} else {
letters
}
}
/// Build plain text + HTML message bodies with inline-code metadata.
fn format_message_bodies(prefix: &str, text: &str) -> (String, String) {
let body = format!("`{}` {}", prefix, text);
@@ -383,12 +380,6 @@ mod tests {
}
}
#[test]
fn modem_preset_short_handles_camelcase() {
assert_eq!(modem_preset_short("LongFast"), "LF");
assert_eq!(modem_preset_short("MediumFast"), "MF");
}
#[test]
fn format_message_bodies_escape_html() {
let (body, formatted) = format_message_bodies("[868][LF]", "Hello <&>");
@@ -757,8 +748,17 @@ mod tests {
/// Drive `handle_message` end-to-end against a mocked Matrix homeserver
/// and PotatoMesh API, asserting that the bridged message body carries
/// the expected protocol tag. Shared by the per-protocol test cases below.
async fn assert_handle_message_emits_tag(protocol: Option<&str>, expected_tag: &str) {
/// the expected protocol tag and preset abbreviation. Shared by the
/// per-protocol test cases below. `lora_freq` is plumbed through both
/// the input message and the expected body so the missing-freq path
/// (`lora_freq = 0`) can be exercised alongside the populated cases.
async fn assert_handle_message_emits_tag(
protocol: Option<&str>,
expected_tag: &str,
modem_preset: &str,
lora_freq: u32,
expected_preset_slot: &str,
) {
let mut server = mockito::Server::new_async().await;
let potatomesh_cfg = PotatomeshConfig {
@@ -822,8 +822,10 @@ mod tests {
.txn_counter
.load(std::sync::atomic::Ordering::SeqCst);
let expected_body = format!("`{expected_tag}[868][MF][TEST]` Ping");
let expected_formatted = format!("<code>{expected_tag}[868][MF][TEST]</code> Ping");
let expected_body =
format!("`{expected_tag}[{lora_freq}][{expected_preset_slot}][TEST]` Ping");
let expected_formatted =
format!("<code>{expected_tag}[{lora_freq}][{expected_preset_slot}][TEST]</code> Ping");
let mock_send = server
.mock(
@@ -849,6 +851,8 @@ mod tests {
let mut state = BridgeState::default();
let msg = PotatoMessage {
protocol: protocol.map(str::to_string),
modem_preset: modem_preset.to_string(),
lora_freq,
..sample_msg(100)
};
@@ -866,21 +870,33 @@ mod tests {
#[tokio::test]
async fn handle_message_tags_meshtastic_in_body() {
assert_handle_message_emits_tag(Some("meshtastic"), "[MT]").await;
assert_handle_message_emits_tag(Some("meshtastic"), "[MT]", "MediumFast", 868, "MF").await;
}
#[tokio::test]
async fn handle_message_defaults_missing_protocol_to_meshtastic_tag() {
assert_handle_message_emits_tag(None, "[MT]").await;
assert_handle_message_emits_tag(None, "[MT]", "MediumFast", 868, "MF").await;
}
#[tokio::test]
async fn handle_message_tags_meshcore_in_body() {
assert_handle_message_emits_tag(Some("meshcore"), "[MC]").await;
// SF8/BW62/CR8 is EU/UK Narrow → bandwidth-driven short code "Na"
// → uppercased "NA" in the bracket slot. Exercises the bug fix.
assert_handle_message_emits_tag(Some("meshcore"), "[MC]", "SF8/BW62/CR8", 868, "NA").await;
}
#[tokio::test]
async fn handle_message_tags_unknown_protocol_as_placeholder() {
assert_handle_message_emits_tag(Some("reticulum"), "[??]").await;
assert_handle_message_emits_tag(Some("reticulum"), "[??]", "MediumFast", 868, "MF").await;
}
#[tokio::test]
async fn handle_message_treats_zero_lora_freq_as_unknown_freq() {
// `lora_freq = 0` stands in for "unknown frequency" — the call
// site collapses it to `None` so frequency-gated named-preset
// lookups are skipped (matching JS `normalizeFrequency`). The
// BW-derived short code still resolves, so SF7/BW62/CR5 renders
// as `[NA]` even without a frequency to disambiguate the region.
assert_handle_message_emits_tag(Some("meshcore"), "[MC]", "SF7/BW62/CR5", 0, "NA").await;
}
}
+776
View File
@@ -0,0 +1,776 @@
// 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.
//! Modem preset abbreviation logic, mirroring
//! `web/public/assets/js/app/node-modem-metadata.js` and
//! `web/public/assets/js/app/chat-format.js`.
//!
//! The PotatoMesh ingestor encodes MeshCore radio config as
//! `SF{sf}/BW{bw}/CR{cr}` and Meshtastic radio config as a CamelCase preset
//! name like `MediumFast`. The web dashboard collapses both to a 2-character
//! bracket label (e.g. `[NA]` for EU/UK Narrow, `[MF]` for MediumFast); this
//! module reproduces that mapping in Rust so Matrix-bridged messages render
//! the same label as the dashboard.
/// Named MeshCore SF/BW/CR preset entry.
///
/// Frequency-gated entries (currently only the SF7/BW62/CR5 row) are skipped
/// when `freq_mhz` is `None`, matching the JS `resolveMeshcorePresetDisplay`
/// behavior.
struct NamedPreset {
sf: u8,
bw: u16,
cr: u8,
long_name: &'static str,
/// Inclusive lower bound for `freq_mhz`. `None` means no lower gate.
min_freq_mhz: Option<u16>,
/// Exclusive upper bound for `freq_mhz`. `None` means no upper gate.
max_freq_mhz: Option<u16>,
}
/// Canonical MeshCore preset table, ported from
/// `MESHCORE_NAMED_PRESETS` in `node-modem-metadata.js:84-92`.
const MESHCORE_NAMED_PRESETS: &[NamedPreset] = &[
NamedPreset {
sf: 10,
bw: 250,
cr: 5,
long_name: "AU/NZ Wide",
min_freq_mhz: None,
max_freq_mhz: None,
},
NamedPreset {
sf: 10,
bw: 62,
cr: 5,
long_name: "AU/NZ Narrow",
min_freq_mhz: None,
max_freq_mhz: None,
},
NamedPreset {
sf: 11,
bw: 250,
cr: 5,
long_name: "EU/UK Wide",
min_freq_mhz: None,
max_freq_mhz: None,
},
NamedPreset {
sf: 8,
bw: 62,
cr: 8,
long_name: "EU/UK Narrow",
min_freq_mhz: None,
max_freq_mhz: None,
},
// SF7/BW62/CR5 is region-disambiguated by the 900 MHz threshold.
NamedPreset {
sf: 7,
bw: 62,
cr: 5,
long_name: "CZ/SK Narrow",
min_freq_mhz: None,
max_freq_mhz: Some(900),
},
NamedPreset {
sf: 7,
bw: 62,
cr: 5,
long_name: "US/CA Narrow",
min_freq_mhz: Some(900),
max_freq_mhz: None,
},
];
/// Canonical Meshtastic preset abbreviation table, ported from
/// `PRESET_ABBREVIATIONS` in `chat-format.js:160-170`.
///
/// Keys are already lowercased and stripped of non-alphabetic characters so
/// the lookup is insensitive to delimiters and casing.
const MESHTASTIC_PRESET_ABBREVIATIONS: &[(&str, &str)] = &[
("verylongslow", "VL"),
("longslow", "LS"),
("longmoderate", "LM"),
("longfast", "LF"),
("mediumslow", "MS"),
("mediumfast", "MF"),
("shortslow", "SS"),
("shortfast", "SF"),
("shortturbo", "ST"),
];
/// Identity of one parsed token in an SF/BW/CR preset string.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum PresetKey {
Sf,
Bw,
Cr,
}
/// Parsed numeric values extracted from an SF/BW/CR preset string.
///
/// JS uses double-precision floats throughout; mirroring with `f64` avoids
/// surprises for any future fractional MHz value even though the values
/// in play today (62, 62.5, 125, 250, ~868915) are exact in `f32`.
#[derive(Clone, Copy, PartialEq, Debug)]
struct MeshcoreTokens {
sf: f64,
bw: f64,
cr: f64,
}
/// Validate that `s` matches the JS regex `\d+(?:\.\d+)?`.
///
/// Accepts integer or decimal positive numbers — no sign, no exponent, no
/// leading or trailing dot.
fn is_valid_number_token(s: &str) -> bool {
let mut has_digits_before_dot = false;
let mut found_dot = false;
let mut has_digits_after_dot = false;
for c in s.chars() {
if c == '.' {
if found_dot || !has_digits_before_dot {
return false;
}
found_dot = true;
} else if c.is_ascii_digit() {
if found_dot {
has_digits_after_dot = true;
} else {
has_digits_before_dot = true;
}
} else {
return false;
}
}
has_digits_before_dot && (!found_dot || has_digits_after_dot)
}
/// Parse a single `SF{n}`, `BW{n}`, or `CR{n}` token (case-insensitive).
fn parse_token(part: &str) -> Option<(PresetKey, f64)> {
// Both length and char-boundary checks are needed: `len() >= 3` rules
// out short tokens, but a multi-byte first codepoint (e.g. `é12`) has
// `len() == 3` while byte index 2 lands mid-codepoint — so
// `is_char_boundary(2)` is what actually keeps `split_at(2)` from
// panicking on non-ASCII input.
if part.len() < 3 || !part.is_char_boundary(2) {
return None;
}
let (prefix, rest) = part.split_at(2);
let key = if prefix.eq_ignore_ascii_case("SF") {
PresetKey::Sf
} else if prefix.eq_ignore_ascii_case("BW") {
PresetKey::Bw
} else if prefix.eq_ignore_ascii_case("CR") {
PresetKey::Cr
} else {
return None;
};
if !is_valid_number_token(rest) {
return None;
}
let value: f64 = rest.parse().ok()?;
Some((key, value))
}
/// Parse an SF/BW/CR preset string into its three components.
///
/// Tokens may appear in any order; the prefix matching is case-insensitive.
/// Returns `None` for any string that is not a 3-segment SF/BW/CR pattern,
/// matching JS `parseMeshcorePresetTokens`.
fn parse_meshcore_preset_tokens(preset: &str) -> Option<MeshcoreTokens> {
let trimmed = preset.trim();
if trimmed.is_empty() {
return None;
}
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 3 {
return None;
}
let mut sf: Option<f64> = None;
let mut bw: Option<f64> = None;
let mut cr: Option<f64> = None;
for part in parts {
let (key, value) = parse_token(part)?;
match key {
PresetKey::Sf => {
if sf.is_some() {
return None;
}
sf = Some(value);
}
PresetKey::Bw => {
if bw.is_some() {
return None;
}
bw = Some(value);
}
PresetKey::Cr => {
if cr.is_some() {
return None;
}
cr = Some(value);
}
}
}
Some(MeshcoreTokens {
sf: sf?,
bw: bw?,
cr: cr?,
})
}
/// Map a LoRa bandwidth to the canonical 2-character short code.
///
/// Mirrors `bwToShortCode` in `node-modem-metadata.js:132-138` — `62` and
/// `62.5` collapse to `Na`, `125` to `St`, `250` to `Wi`. Any other value
/// returns `None`.
///
/// The `f64 ==` comparisons rely on each literal having an exact double
/// representation; `62`, `62.5`, `125`, and `250` all do, and tokens
/// reach this function via `f64::from_str` of plain decimal strings, so
/// no rounding is introduced upstream.
fn bw_to_short_code(bw: f64) -> Option<&'static str> {
if bw == 62.0 || bw == 62.5 {
Some("Na")
} else if bw == 125.0 {
Some("St")
} else if bw == 250.0 {
Some("Wi")
} else {
None
}
}
/// Format a numeric token for display string construction.
///
/// Mirrors JS coercion: `62` renders as `"62"`, `62.5` as `"62.5"`.
fn format_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
/// Display metadata returned by [`resolve_meshcore_preset_display`].
///
/// `long_name` and `display_string` are not consumed by the Matrix bridge
/// today — only `short_code` feeds the bracket render. They are retained
/// (with `#[allow(dead_code)]`) so the port stays line-for-line auditable
/// against the JS source and so a future caller (e.g. a tooltip surface)
/// can read them without touching the parsing path again.
#[derive(Clone, PartialEq, Debug)]
struct MeshcoreDisplay {
/// Long human-readable name (e.g. "EU/UK Wide") when the SF/BW/CR
/// triple matches a named preset, else `None`.
#[allow(dead_code)]
long_name: Option<&'static str>,
/// 2-character short code derived from BW alone (e.g. "Na", "St",
/// "Wi"), or `None` when the BW is unrecognized.
short_code: Option<&'static str>,
/// Human-readable display string — the long name when matched, else
/// `BW{bw}/SF{sf}/CR{cr}`.
#[allow(dead_code)]
display_string: String,
}
/// Resolve a MeshCore SF/BW/CR preset into display metadata, or `None`
/// when the input is not an SF/BW/CR string.
///
/// Mirrors `resolveMeshcorePresetDisplay` in `node-modem-metadata.js:161-190`.
fn resolve_meshcore_preset_display(preset: &str, freq_mhz: Option<f64>) -> Option<MeshcoreDisplay> {
let tokens = parse_meshcore_preset_tokens(preset)?;
let short_code = bw_to_short_code(tokens.bw);
let matched = MESHCORE_NAMED_PRESETS.iter().find(|entry| {
if (entry.sf as f64) != tokens.sf {
return false;
}
if (entry.bw as f64) != tokens.bw {
return false;
}
if (entry.cr as f64) != tokens.cr {
return false;
}
if let Some(max) = entry.max_freq_mhz {
match freq_mhz {
Some(f) if f < max as f64 => {}
_ => return false,
}
}
if let Some(min) = entry.min_freq_mhz {
match freq_mhz {
Some(f) if f >= min as f64 => {}
_ => return false,
}
}
true
});
if let Some(entry) = matched {
return Some(MeshcoreDisplay {
long_name: Some(entry.long_name),
short_code,
display_string: entry.long_name.to_string(),
});
}
Some(MeshcoreDisplay {
long_name: None,
short_code,
display_string: format!(
"BW{}/SF{}/CR{}",
format_number(tokens.bw),
format_number(tokens.sf),
format_number(tokens.cr),
),
})
}
/// Lowercase a Meshtastic preset string for table lookup.
///
/// Mirrors `preset.replace(/[^A-Za-z]/g, '').toLowerCase()` in
/// `chat-format.js:296`.
fn normalize_meshtastic_token(preset: &str) -> String {
preset
.chars()
.filter(|c| c.is_ascii_alphabetic())
.flat_map(|c| c.to_lowercase())
.collect()
}
/// Generate the fallback initials for a preset that did not hit either
/// lookup table.
///
/// Mirrors `derivePresetInitials` in `chat-format.js:309-336`.
fn derive_preset_initials(preset: &str) -> Option<String> {
if preset.is_empty() {
return None;
}
// Insert a space between (lowercase | digit) and uppercase to split
// CamelCase boundaries — mirrors `/([a-z0-9])([A-Z])/g`.
let mut spaced = String::with_capacity(preset.len() + 4);
let mut prev: Option<char> = None;
for c in preset.chars() {
if let Some(p) = prev {
if (p.is_ascii_lowercase() || p.is_ascii_digit()) && c.is_ascii_uppercase() {
spaced.push(' ');
}
}
spaced.push(c);
prev = Some(c);
}
let tokens: Vec<String> = spaced
.split(|c: char| c.is_whitespace() || c == '_' || c == '-')
.map(|part| {
part.chars()
.filter(|c| c.is_ascii_alphabetic())
.collect::<String>()
})
.filter(|s| !s.is_empty())
.collect();
if tokens.is_empty() {
return None;
}
if tokens.len() == 1 {
// Tokens are non-empty after the alphabetic-only filter, so
// `upper` always has ≥ 1 character. The branch reduces to "≥ 2
// → first two chars" vs. "exactly 1 → `X?`" — no zero-length arm.
let upper = tokens[0].to_ascii_uppercase();
if upper.chars().count() >= 2 {
return Some(upper.chars().take(2).collect());
}
return Some(format!("{}?", upper));
}
let first = tokens[0].chars().next()?.to_ascii_uppercase();
let second = tokens[1].chars().next()?.to_ascii_uppercase();
Some(format!("{}{}", first, second))
}
/// Produce a 2-character abbreviation for any modem preset string.
///
/// MeshCore SF/BW/CR presets resolve via [`resolve_meshcore_preset_display`]
/// (taking precedence over the Meshtastic table). Meshtastic named presets
/// hit [`MESHTASTIC_PRESET_ABBREVIATIONS`] after delimiter / casing
/// normalization. Anything else falls through to [`derive_preset_initials`].
///
/// Returns `None` only when the preset is empty or cannot be reduced to a
/// 1+ character abbreviation.
///
/// Mirrors `abbreviatePreset` in `chat-format.js:287-301`.
pub fn abbreviate_preset(preset: &str, freq_mhz: Option<f64>) -> Option<String> {
let trimmed = preset.trim();
if trimmed.is_empty() {
return None;
}
if let Some(display) = resolve_meshcore_preset_display(trimmed, freq_mhz) {
return display.short_code.map(str::to_string);
}
let token = normalize_meshtastic_token(trimmed);
if !token.is_empty() {
for (key, value) in MESHTASTIC_PRESET_ABBREVIATIONS {
if token == *key {
return Some((*value).to_string());
}
}
}
derive_preset_initials(trimmed)
}
/// Format an abbreviation into the 2-character bracket slot used by both
/// the dashboard and the Matrix bridge.
///
/// Trims the value, uppercases it, and truncates to 2 characters. Returns
/// `"??"` when the value is missing or empty so the column width remains
/// consistent.
///
/// Mirrors `normalizePresetSlot` in `chat-format.js:344-350`. Where the JS
/// version emits `&nbsp;&nbsp;` for the empty case (HTML context), this Rust
/// port emits the literal placeholder `"??"` because Matrix message bodies
/// are plain text plus a `<code>…</code>` HTML wrapper, not raw HTML. `"??"`
/// also matches the existing `protocol_tag` placeholder convention.
pub fn normalize_preset_slot(value: Option<&str>) -> String {
let raw = value.unwrap_or("").trim();
if raw.is_empty() {
return "??".to_string();
}
let upper: String = raw.chars().flat_map(|c| c.to_uppercase()).collect();
if upper.is_empty() {
return "??".to_string();
}
upper.chars().take(2).collect()
}
#[cfg(test)]
mod tests {
use super::*;
// ----- is_valid_number_token --------------------------------------------
#[test]
fn number_token_accepts_integers_and_decimals() {
assert!(is_valid_number_token("0"));
assert!(is_valid_number_token("125"));
assert!(is_valid_number_token("62.5"));
}
#[test]
fn number_token_rejects_signs_exponents_and_dotted_edges() {
assert!(!is_valid_number_token(""));
assert!(!is_valid_number_token("."));
assert!(!is_valid_number_token(".5"));
assert!(!is_valid_number_token("5."));
assert!(!is_valid_number_token("+5"));
assert!(!is_valid_number_token("-5"));
assert!(!is_valid_number_token("1e3"));
assert!(!is_valid_number_token("1.2.3"));
}
// ----- parse_token -------------------------------------------------------
#[test]
fn parse_token_handles_each_prefix_case_insensitively() {
assert_eq!(parse_token("SF12"), Some((PresetKey::Sf, 12.0)));
assert_eq!(parse_token("sf12"), Some((PresetKey::Sf, 12.0)));
assert_eq!(parse_token("BW125"), Some((PresetKey::Bw, 125.0)));
assert_eq!(parse_token("bw62.5"), Some((PresetKey::Bw, 62.5)));
assert_eq!(parse_token("CR5"), Some((PresetKey::Cr, 5.0)));
}
#[test]
fn parse_token_rejects_invalid_inputs() {
assert_eq!(parse_token(""), None);
assert_eq!(parse_token("XX12"), None);
assert_eq!(parse_token("SF"), None);
assert_eq!(parse_token("SFabc"), None);
// Non-ASCII first byte must not panic (multi-byte char crosses the
// first-2-bytes boundary).
assert_eq!(parse_token("é12"), None);
}
// ----- parse_meshcore_preset_tokens --------------------------------------
#[test]
fn parse_preset_tokens_accepts_any_order_and_case() {
let parsed = parse_meshcore_preset_tokens("SF12/BW125/CR5").unwrap();
assert_eq!(parsed.sf, 12.0);
assert_eq!(parsed.bw, 125.0);
assert_eq!(parsed.cr, 5.0);
let reordered = parse_meshcore_preset_tokens("cr5/sf7/bw62.5").unwrap();
assert_eq!(reordered.sf, 7.0);
assert_eq!(reordered.bw, 62.5);
assert_eq!(reordered.cr, 5.0);
}
#[test]
fn parse_preset_tokens_rejects_non_sf_bw_cr_inputs() {
assert!(parse_meshcore_preset_tokens("MediumFast").is_none());
assert!(parse_meshcore_preset_tokens("").is_none());
assert!(parse_meshcore_preset_tokens("SF12/BW125").is_none());
assert!(parse_meshcore_preset_tokens("SF12/BW125/CR5/extra").is_none());
// Duplicate token rejected to avoid silently dropping ambiguous input.
assert!(parse_meshcore_preset_tokens("SF12/SF12/CR5").is_none());
}
// ----- bw_to_short_code --------------------------------------------------
#[test]
fn bw_short_code_matches_canonical_table() {
assert_eq!(bw_to_short_code(62.0), Some("Na"));
assert_eq!(bw_to_short_code(62.5), Some("Na"));
assert_eq!(bw_to_short_code(125.0), Some("St"));
assert_eq!(bw_to_short_code(250.0), Some("Wi"));
assert_eq!(bw_to_short_code(500.0), None);
assert_eq!(bw_to_short_code(31.0), None);
}
// ----- format_number -----------------------------------------------------
#[test]
fn format_number_drops_decimal_for_integers() {
assert_eq!(format_number(0.0), "0");
assert_eq!(format_number(62.0), "62");
assert_eq!(format_number(125.0), "125");
}
#[test]
fn format_number_keeps_decimal_for_fractions() {
assert_eq!(format_number(0.5), "0.5");
assert_eq!(format_number(62.5), "62.5");
}
// ----- resolve_meshcore_preset_display -----------------------------------
#[test]
fn resolve_returns_none_for_non_sf_bw_cr_input() {
assert!(resolve_meshcore_preset_display("MediumFast", None).is_none());
assert!(resolve_meshcore_preset_display("", None).is_none());
}
#[test]
fn resolve_au_nz_wide_at_915mhz() {
let got = resolve_meshcore_preset_display("SF10/BW250/CR5", Some(915.0)).unwrap();
assert_eq!(got.long_name, Some("AU/NZ Wide"));
assert_eq!(got.short_code, Some("Wi"));
assert_eq!(got.display_string, "AU/NZ Wide");
}
#[test]
fn resolve_au_nz_narrow_at_915mhz() {
let got = resolve_meshcore_preset_display("SF10/BW62/CR5", Some(915.0)).unwrap();
assert_eq!(got.long_name, Some("AU/NZ Narrow"));
assert_eq!(got.short_code, Some("Na"));
assert_eq!(got.display_string, "AU/NZ Narrow");
}
#[test]
fn resolve_eu_uk_wide_at_868mhz() {
let got = resolve_meshcore_preset_display("SF11/BW250/CR5", Some(868.0)).unwrap();
assert_eq!(got.long_name, Some("EU/UK Wide"));
assert_eq!(got.short_code, Some("Wi"));
assert_eq!(got.display_string, "EU/UK Wide");
}
#[test]
fn resolve_eu_uk_narrow_at_868mhz() {
let got = resolve_meshcore_preset_display("SF8/BW62/CR8", Some(868.0)).unwrap();
assert_eq!(got.long_name, Some("EU/UK Narrow"));
assert_eq!(got.short_code, Some("Na"));
assert_eq!(got.display_string, "EU/UK Narrow");
}
#[test]
fn resolve_cz_sk_narrow_below_900mhz() {
let got = resolve_meshcore_preset_display("SF7/BW62/CR5", Some(868.0)).unwrap();
assert_eq!(got.long_name, Some("CZ/SK Narrow"));
assert_eq!(got.short_code, Some("Na"));
assert_eq!(got.display_string, "CZ/SK Narrow");
}
#[test]
fn resolve_us_ca_narrow_at_or_above_900mhz() {
let got915 = resolve_meshcore_preset_display("SF7/BW62/CR5", Some(915.0)).unwrap();
assert_eq!(got915.long_name, Some("US/CA Narrow"));
let got_boundary = resolve_meshcore_preset_display("SF7/BW62/CR5", Some(900.0)).unwrap();
assert_eq!(got_boundary.long_name, Some("US/CA Narrow"));
}
#[test]
fn resolve_unknown_freq_skips_gated_named_match() {
let got = resolve_meshcore_preset_display("SF7/BW62/CR5", None).unwrap();
assert_eq!(got.long_name, None);
assert_eq!(got.short_code, Some("Na"));
assert_eq!(got.display_string, "BW62/SF7/CR5");
}
#[test]
fn resolve_unknown_bw_has_no_short_code() {
let got = resolve_meshcore_preset_display("SF12/BW500/CR7", None).unwrap();
assert_eq!(got.long_name, None);
assert_eq!(got.short_code, None);
assert_eq!(got.display_string, "BW500/SF12/CR7");
}
#[test]
fn resolve_125khz_falls_back_to_st() {
let got = resolve_meshcore_preset_display("SF9/BW125/CR6", None).unwrap();
assert_eq!(got.long_name, None);
assert_eq!(got.short_code, Some("St"));
assert_eq!(got.display_string, "BW125/SF9/CR6");
}
// ----- abbreviate_preset (MeshCore path) ---------------------------------
#[test]
fn abbreviate_returns_meshcore_short_code_for_named_presets() {
assert_eq!(
abbreviate_preset("SF11/BW250/CR5", Some(868.0)).as_deref(),
Some("Wi")
);
assert_eq!(
abbreviate_preset("SF8/BW62/CR8", Some(868.0)).as_deref(),
Some("Na")
);
assert_eq!(
abbreviate_preset("SF10/BW250/CR5", Some(915.0)).as_deref(),
Some("Wi")
);
}
#[test]
fn abbreviate_returns_meshcore_short_code_via_bw_fallback() {
assert_eq!(
abbreviate_preset("SF9/BW125/CR6", None).as_deref(),
Some("St")
);
assert_eq!(
abbreviate_preset("SF7/BW62/CR5", None).as_deref(),
Some("Na")
);
}
#[test]
fn abbreviate_returns_none_when_meshcore_bw_unknown() {
assert_eq!(abbreviate_preset("SF12/BW500/CR7", None), None);
}
// ----- abbreviate_preset (Meshtastic path) -------------------------------
#[test]
fn abbreviate_resolves_every_named_meshtastic_preset() {
let cases = [
("VeryLongSlow", "VL"),
("LongSlow", "LS"),
("LongModerate", "LM"),
("LongFast", "LF"),
("MediumSlow", "MS"),
("MediumFast", "MF"),
("ShortSlow", "SS"),
("ShortFast", "SF"),
("ShortTurbo", "ST"),
];
for (input, expected) in cases {
assert_eq!(
abbreviate_preset(input, None).as_deref(),
Some(expected),
"input={input}"
);
}
}
#[test]
fn abbreviate_is_insensitive_to_delimiters_and_case() {
assert_eq!(abbreviate_preset("LONG_FAST", None).as_deref(), Some("LF"));
assert_eq!(abbreviate_preset("long-fast", None).as_deref(), Some("LF"));
assert_eq!(
abbreviate_preset("Medium_Fast", None).as_deref(),
Some("MF")
);
// Whitespace is stripped along with other non-alphabetic chars,
// so a human-typed `"Medium Fast"` resolves the same as the
// CamelCase form.
assert_eq!(
abbreviate_preset("Medium Fast", None).as_deref(),
Some("MF")
);
}
// ----- abbreviate_preset (initials fallback) -----------------------------
#[test]
fn abbreviate_falls_back_to_initials_for_unmapped_camelcase() {
assert_eq!(
abbreviate_preset("CustomPreset", None).as_deref(),
Some("CP")
);
}
#[test]
fn abbreviate_handles_single_word_and_letter_inputs() {
assert_eq!(abbreviate_preset("Foo", None).as_deref(), Some("FO"));
assert_eq!(abbreviate_preset("X", None).as_deref(), Some("X?"));
}
#[test]
fn abbreviate_returns_none_for_blank_or_punctuation_only() {
assert_eq!(abbreviate_preset("", None), None);
assert_eq!(abbreviate_preset(" ", None), None);
assert_eq!(abbreviate_preset("___", None), None);
}
// ----- normalize_preset_slot --------------------------------------------
#[test]
fn normalize_slot_uppercases_and_truncates() {
assert_eq!(normalize_preset_slot(Some("Na")), "NA");
assert_eq!(normalize_preset_slot(Some("MF")), "MF");
assert_eq!(normalize_preset_slot(Some("verylong")), "VE");
assert_eq!(normalize_preset_slot(Some(" st ")), "ST");
}
#[test]
fn normalize_slot_emits_placeholder_for_missing_or_empty() {
assert_eq!(normalize_preset_slot(None), "??");
assert_eq!(normalize_preset_slot(Some("")), "??");
assert_eq!(normalize_preset_slot(Some(" ")), "??");
}
// ----- derive_preset_initials -------------------------------------------
#[test]
fn derive_initials_handles_token_count_branches() {
assert_eq!(derive_preset_initials(""), None);
assert_eq!(derive_preset_initials("___"), None);
assert_eq!(derive_preset_initials("Foo"), Some("FO".to_string()));
assert_eq!(derive_preset_initials("X"), Some("X?".to_string()));
assert_eq!(
derive_preset_initials("CustomPreset"),
Some("CP".to_string())
);
assert_eq!(
derive_preset_initials("Three Word Name"),
Some("TW".to_string())
);
}
}
+17 -2
View File
@@ -48,12 +48,26 @@ RUN python3 -m venv /opt/meshtastic-venv && \
# Production stage
FROM ruby:3.3-alpine AS production
# Install runtime dependencies
# Build-time toggle controlling whether Chromium is bundled into the image
# for runtime Open Graph preview rendering. Operators on size-constrained
# hosts can build with `--build-arg WITH_OG_IMAGE=0` to skip Chromium and
# its font/library payload (~150 MB). The web app falls back to the
# packaged default PNG when Chromium is missing, and operators can point
# `OG_IMAGE_URL` at a CDN-hosted preview instead.
ARG WITH_OG_IMAGE=1
ENV WITH_OG_IMAGE=${WITH_OG_IMAGE}
# Install runtime dependencies. Chromium powers the runtime Open Graph
# preview generator; the accompanying font and library packages are the
# minimum set required to render the dashboard headlessly on Alpine.
RUN apk add --no-cache \
python3 \
sqlite \
tzdata \
curl
curl \
&& if [ "$WITH_OG_IMAGE" = "1" ]; then \
apk add --no-cache chromium nss freetype harfbuzz ttf-freefont; \
fi
# Create non-root user
RUN addgroup -g 1000 -S potatomesh && \
@@ -107,6 +121,7 @@ ENV RACK_ENV=production \
MAP_ZOOM="" \
MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \
FERRUM_BROWSER_PATH=/usr/bin/chromium \
DEBUG=0
# Start the application
+1
View File
@@ -22,6 +22,7 @@ gem "puma", "~> 7.0"
gem "prometheus-client"
gem "kramdown", "~> 2.4"
gem "kramdown-parser-gfm", "~> 1.1"
gem "ferrum", "~> 0.17"
group :test do
gem "rspec", "~> 3.12"
+1
View File
@@ -40,6 +40,7 @@ require_relative "config"
require_relative "sanitizer"
require_relative "meta"
require_relative "logging"
require_relative "og_image"
require_relative "application/helpers"
require_relative "application/errors"
require_relative "application/database"
@@ -74,9 +74,18 @@ module PotatoMesh
# Generate the structured meta configuration for the UI.
#
# @param view [Symbol, String, nil] logical view identifier used to
# tailor the title and description for non-dashboard pages.
# @param overrides [Hash, nil] explicit replacements for individual
# meta fields. See {PotatoMesh::Meta.configuration} for accepted
# keys.
# @return [Hash] frozen configuration metadata.
def meta_configuration
PotatoMesh::Meta.configuration(private_mode: private_mode?)
def meta_configuration(view: nil, overrides: nil)
PotatoMesh::Meta.configuration(
private_mode: private_mode?,
view: view,
overrides: overrides,
)
end
# Indicate whether private mode has been requested.
+14 -1
View File
@@ -274,16 +274,29 @@ module PotatoMesh
end
# Emit a debug entry describing how the instance domain was derived.
# When +INSTANCE_DOMAIN+ is unset in production, also surface a
# warning because canonical URLs, sitemap entries, and JSON-LD
# metadata fall back to whatever +Host+ header the request arrived
# with — which can be cache-poisoned by a misconfigured proxy.
#
# @return [void]
def log_instance_domain_resolution
source = app_constant(:INSTANCE_DOMAIN_SOURCE) || :unknown
domain = app_constant(:INSTANCE_DOMAIN)
debug_log(
"Resolved instance domain",
context: "identity.domain",
source: source,
domain: app_constant(:INSTANCE_DOMAIN),
domain: domain,
)
if production_environment? && (domain.nil? || domain.to_s.strip.empty?)
warn_log(
"INSTANCE_DOMAIN is unset; canonical URLs and sitemap entries " \
"will be derived from the inbound Host header",
context: "identity.domain",
source: source,
)
end
end
end
end
+186 -4
View File
@@ -17,6 +17,7 @@
require "kramdown"
require "kramdown-parser-gfm"
require "sanitize"
require "yaml"
module PotatoMesh
module App
@@ -36,10 +37,29 @@ module PotatoMesh
# @!attribute [r] slug
# @return [String] URL-safe identifier derived from the filename.
# @!attribute [r] title
# @return [String] human-readable nav label.
# @return [String] human-readable nav label, optionally overridden
# via YAML frontmatter.
# @!attribute [r] path
# @return [String] absolute filesystem path to the Markdown source.
PageEntry = Struct.new(:sort_key, :slug, :title, :path, keyword_init: true)
# @!attribute [r] description
# @return [String, nil] meta-description override sourced from
# frontmatter, or +nil+ when the global default should be used.
# @!attribute [r] image
# @return [String, nil] absolute URL for the per-page social preview
# image, or +nil+ when the default OG image should be used.
# @!attribute [r] noindex
# @return [Boolean] +true+ when the operator marked the page with
# +noindex: true+ in frontmatter; instructs crawlers to skip it.
PageEntry = Struct.new(
:sort_key,
:slug,
:title,
:path,
:description,
:image,
:noindex,
keyword_init: true,
)
# Pattern matching a safe slug segment: lowercase alphanumeric words
# separated by single hyphens. Used to validate both parsed slugs and
@@ -54,6 +74,20 @@ module PotatoMesh
# directory-bomb scenarios from consuming unbounded memory.
MAX_PAGES = 50
# Maximum number of bytes inspected when extracting frontmatter from a
# candidate file during directory scans. Keeps {load_static_pages}
# cheap for large markdown files.
FRONTMATTER_PROBE_BYTES = 4096
# Set of frontmatter keys that operators may use to influence how a
# page is presented to crawlers and social platforms. Any other key in
# the document is silently ignored to keep the surface area small and
# the parser predictable.
ALLOWED_FRONTMATTER_KEYS = %w[title description image noindex].freeze
# Pattern used to recognise a leading YAML frontmatter block.
FRONTMATTER_PATTERN = /\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m
# Kramdown options shared across all page renders.
KRAMDOWN_OPTIONS = {
input: "GFM",
@@ -100,6 +134,151 @@ module PotatoMesh
PageEntry.new(sort_key: sort_key, slug: slug, title: title, path: nil)
end
# Extract the frontmatter block (if any) from raw markdown source.
#
# The first +---+ delimited block is parsed via {YAML.safe_load}; only
# keys listed in {ALLOWED_FRONTMATTER_KEYS} are kept and string values
# are stripped. Malformed YAML, unsupported types, and missing
# delimiters all result in an empty hash so the caller can fall back to
# filename-derived metadata without raising.
#
# @param content [String] raw file contents (UTF-8).
# @return [Hash{String=>Object}] permitted, normalised frontmatter
# values.
def parse_frontmatter(content)
return {} unless content.is_a?(String)
match = content.match(FRONTMATTER_PATTERN)
return {} unless match
begin
parsed = YAML.safe_load(match[1], permitted_classes: [], aliases: false) || {}
rescue Psych::Exception
return {}
end
return {} unless parsed.is_a?(Hash)
parsed.each_with_object({}) do |(key, value), result|
string_key = key.to_s
next unless ALLOWED_FRONTMATTER_KEYS.include?(string_key)
result[string_key] = normalise_frontmatter_value(string_key, value)
end
end
# Strip a leading frontmatter block from the raw markdown body.
#
# @param content [String] file contents.
# @return [String] markdown body without frontmatter.
def strip_frontmatter(content)
return content unless content.is_a?(String)
content.sub(FRONTMATTER_PATTERN, "")
end
# Coerce frontmatter values into the canonical type expected for each
# supported key. String fields are trimmed; +noindex+ is forced into a
# strict boolean; +image+ additionally enforces an +http(s)+ scheme
# so an operator who pastes a +data:+, +javascript:+, or relative
# URI does not silently leak it into the +og:image+ tag. Unrecognised
# values fall through to +nil+/+false+ so the rest of the pipeline
# can rely on simple checks.
#
# @param key [String] supported frontmatter key.
# @param value [Object] raw parsed value from {YAML.safe_load}.
# @return [String, Boolean, nil] normalised value.
def normalise_frontmatter_value(key, value)
case key
when "noindex"
truthy_frontmatter?(value)
when "image"
normalise_image_url(value)
else
string = value.is_a?(String) ? value : value.to_s
stripped = string.strip
stripped.empty? ? nil : stripped
end
end
# Validate an operator-supplied image URL. Only +http(s)+ schemes are
# accepted — +data:+, +javascript:+, relative paths, and other
# exotic forms are dropped silently because they would either fail
# to render in social-media link previews or open a content-security
# foot-gun.
#
# @param value [Object] raw frontmatter value.
# @return [String, nil] absolute URL or +nil+ when invalid/blank.
def normalise_image_url(value)
string = value.is_a?(String) ? value : value.to_s
stripped = string.strip
return nil if stripped.empty?
return nil unless stripped.match?(%r{\Ahttps?://}i)
stripped
end
# Decide whether a frontmatter scalar should be treated as truthy.
#
# Accepts native booleans as well as the common string aliases
# +"true"+, +"yes"+, +"1"+, +"on"+ (case-insensitive) so operators do
# not have to remember YAML's exact boolean coercion rules.
#
# @param value [Object] candidate value.
# @return [Boolean] +true+ when the value should map to truth.
def truthy_frontmatter?(value)
return value if value == true || value == false
normalised = value.to_s.strip.downcase
%w[true yes 1 on].include?(normalised)
end
# Read up to {FRONTMATTER_PROBE_BYTES} of the file at +path+ for
# frontmatter inspection during directory scans. Returns an empty
# string for unreadable or oversized inputs so the caller can treat
# them as having no frontmatter.
#
# The result is force-encoded to UTF-8 because YAML parsers refuse
# input declared as binary; for files that are already UTF-8 this
# is a no-op, and for files in another encoding it surfaces a
# decoding error to the YAML parser instead of silently producing
# gibberish that happens to match the frontmatter delimiters.
#
# @param path [String] absolute path to the markdown source.
# @return [String] candidate frontmatter prefix.
def read_frontmatter_probe(path)
return "" unless File.file?(path) && File.readable?(path)
raw = File.open(path, "r:UTF-8") { |file| file.read(FRONTMATTER_PROBE_BYTES) || "" }
raw.force_encoding(Encoding::UTF_8)
rescue SystemCallError
""
end
# Apply parsed frontmatter values to a {PageEntry}, returning a new
# struct that preserves filename-derived defaults whenever a key is
# absent or blank.
#
# {parse_frontmatter} has already dropped blank string values for
# +title+/+description+/+image+, so this method can rely on truthy
# checks rather than re-validating each key.
#
# @param entry [PageEntry] base entry parsed from the filename.
# @param frontmatter [Hash] permitted frontmatter values.
# @return [PageEntry] enriched entry.
def apply_frontmatter(entry, frontmatter)
return entry unless entry
PageEntry.new(
sort_key: entry.sort_key,
slug: entry.slug,
title: frontmatter["title"] || entry.title,
path: entry.path,
description: frontmatter["description"],
image: frontmatter["image"],
noindex: frontmatter["noindex"] == true,
)
end
# Scan the pages directory and return a sorted list of page entries.
#
# The directory is read once per call; results are not cached here (see
@@ -116,12 +295,14 @@ module PotatoMesh
entry = parse_page_filename(basename)
next unless entry
PageEntry.new(
base_entry = PageEntry.new(
sort_key: entry.sort_key,
slug: entry.slug,
title: entry.title,
path: path,
)
frontmatter = parse_frontmatter(read_frontmatter_probe(path))
apply_frontmatter(base_entry, frontmatter)
end
entries.sort_by!(&:sort_key)
@@ -174,7 +355,8 @@ module PotatoMesh
return nil if size > PotatoMesh::Config.max_page_file_bytes
content = File.read(page_entry.path, encoding: "utf-8")
raw_html = Kramdown::Document.new(content, **KRAMDOWN_OPTIONS).to_html
body = strip_frontmatter(content)
raw_html = Kramdown::Document.new(body, **KRAMDOWN_OPTIONS).to_html
strip_unsafe_html(raw_html)
rescue SystemCallError
nil
+257 -3
View File
@@ -19,6 +19,18 @@ module PotatoMesh
module Routes
module Root
module Helpers
# Map of XML predefined entities used by {#xml_escape}.
XML_ESCAPE_REPLACEMENTS = {
"&" => "&amp;",
"<" => "&lt;",
">" => "&gt;",
'"' => "&quot;",
"'" => "&apos;",
}.freeze
# Pattern matching any XML metacharacter that requires escaping.
XML_ESCAPE_PATTERN = Regexp.union(XML_ESCAPE_REPLACEMENTS.keys).freeze
# Return the fixed dark theme identifier. Light mode is no longer
# supported; theme selection and cookie persistence have been removed.
#
@@ -31,19 +43,28 @@ module PotatoMesh
#
# @param template [Symbol] identifier for the ERB template.
# @param view_mode [Symbol, String] logical view identifier for CSS hooks.
# @param view_meta [Symbol, String, nil] meta-tag selector. Defaults to
# +view_mode+ so most callers can omit it; pass an explicit value
# when the layout view differs from the meta archetype (e.g. the
# dynamic +/pages/:slug+ routes whose view_mode is per-slug).
# @param meta_overrides [Hash, nil] explicit replacements for
# individual meta values (title, description, image, noindex).
# @param extra_locals [Hash] additional locals merged into the rendering context.
# @return [String] rendered ERB output.
def render_root_view(template, view_mode: :dashboard, extra_locals: {})
meta = meta_configuration
def render_root_view(template, view_mode: :dashboard, view_meta: nil, meta_overrides: nil, extra_locals: {})
view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
view_meta_sym = view_meta.nil? ? view_mode_sym : (view_meta.respond_to?(:to_sym) ? view_meta.to_sym : view_meta)
meta = meta_configuration(view: view_meta_sym, overrides: meta_overrides)
config = frontend_app_config
theme = resolve_initial_theme
view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
base_locals = {
site_name: meta[:name],
meta_title: meta[:title],
meta_name: meta[:name],
meta_description: meta[:description],
meta_image_url: meta[:image],
meta_noindex: meta[:noindex] == true,
channel: sanitized_channel,
frequency: sanitized_frequency,
map_center_lat: PotatoMesh::Config.map_center_lat,
@@ -135,6 +156,203 @@ module PotatoMesh
"position" => position,
}
end
# Resolve the canonical absolute base URL for the running request.
# Prefers an operator-supplied override (+INSTANCE_DOMAIN+) so
# generated absolute URLs match the public-facing hostname, falling
# back to the request's own +base_url+ for development.
#
# @return [String] base URL (scheme + authority) without trailing slash.
def public_base_url
domain = string_or_nil(app_constant(:INSTANCE_DOMAIN))
return request.base_url unless domain
scheme = request.scheme || "https"
"#{scheme}://#{domain}"
end
# Construct the OG image URL referenced from the layout. Operators
# may provide an explicit override via +OG_IMAGE_URL+; otherwise
# the runtime-generated +/og-image.png+ URL is returned.
#
# The override is rejected unless it carries an +http(s)+ scheme.
# +data:+ and +javascript:+ URIs do not render in any social
# platform's link preview and would only serve as a content
# security foot-gun, so they are silently dropped in favour of
# the runtime URL.
#
# @return [String] absolute URL to the social preview image.
def og_image_url
override = string_or_nil(PotatoMesh::Config.og_image_url)
return override if override && override.match?(%r{\Ahttps?://}i)
"#{public_base_url}/og-image.png"
end
# Build the title segment for the node detail view from the data
# already resolved by {build_node_detail_reference}.
#
# @param short_name [String, nil] sanitized short identifier.
# @param long_name [String, nil] sanitized long name.
# @param canonical_id [String, nil] canonical "!hex" identifier.
# @return [String] human-friendly node label.
def node_detail_title_label(short_name:, long_name:, canonical_id:)
short = string_or_nil(short_name)
long = string_or_nil(long_name)
return "#{short} (#{long})" if short && long
return short if short
return long if long
return "Node #{canonical_id}" if canonical_id
"Node detail"
end
# Compose meta overrides for the +/nodes/:id+ view.
#
# @param short_name [String, nil] sanitized short identifier.
# @param long_name [String, nil] sanitized long name.
# @param canonical_id [String, nil] canonical "!hex" identifier.
# @return [Hash] override hash for {meta_configuration}.
def node_detail_meta_overrides(short_name:, long_name:, canonical_id:)
site = sanitized_site_name
label = node_detail_title_label(
short_name: short_name,
long_name: long_name,
canonical_id: canonical_id,
)
description_subject = string_or_nil(short_name) || string_or_nil(long_name) ||
canonical_id || "this node"
{
title: site && !site.empty? ? "#{label} · #{site}" : label,
description: "Telemetry, position history, and live status for node #{description_subject} on #{site}.",
}
end
# Compose meta overrides for the +/pages/:slug+ view from a static
# page entry plus any frontmatter the operator defined.
#
# Only keys that carry meaningful values are included so the
# downstream {meta_configuration} call is not asked to filter
# +nil+ values out of an otherwise sparse hash.
#
# @param page [PotatoMesh::App::Pages::PageEntry] resolved page entry.
# @return [Hash] override hash for {meta_configuration}.
def static_page_meta_overrides(page)
site = sanitized_site_name
title_segment = string_or_nil(page&.title) || ""
composed_title = if !title_segment.empty? && site && !site.empty?
"#{title_segment} · #{site}"
elsif !title_segment.empty?
title_segment
else
site
end
overrides = { title: composed_title }
description = string_or_nil(page&.description)
overrides[:description] = description if description
image = string_or_nil(page&.image)
overrides[:image] = image if image
overrides[:noindex] = true if page&.noindex == true
overrides
end
# Render the +robots.txt+ body honoring private-mode preferences.
# Private deployments emit a blanket disallow; public deployments
# whitelist the dashboard while disallowing instrumentation paths.
#
# @param sitemap_url [String] absolute URL of the public sitemap.
# @return [String] +robots.txt+ payload, terminated with a newline.
def build_robots_txt(sitemap_url)
if private_mode?
"User-agent: *\nDisallow: /\n"
else
<<~TXT
User-agent: *
Disallow: /metrics
Disallow: /api/
Sitemap: #{sitemap_url}
TXT
end
end
# Build the URL list emitted by +/sitemap.xml+ for a public
# deployment. Each entry is a hash with +:loc+ and an optional
# +:lastmod+ / +:changefreq+ pair.
#
# +lastmod+ is intentionally omitted for top-level dashboard
# routes. The data behind those views changes continuously, so
# advertising +Time.now+ on every crawl trains crawlers to ignore
# the field (Google explicitly discourages noisy +lastmod+
# values). Static pages keep a meaningful +lastmod+ derived from
# +File.mtime+.
#
# The handler at +/sitemap.xml+ already 404s in private mode, so
# this method does not need to filter chat — it is unreachable
# otherwise.
#
# @param base_url [String] absolute base URL prefix.
# @return [Array<Hash>] ordered list of sitemap entries.
def build_sitemap_entries(base_url)
entries = []
entries << { loc: "#{base_url}/", changefreq: "daily" }
entries << { loc: "#{base_url}/map", changefreq: "daily" }
entries << { loc: "#{base_url}/chat", changefreq: "daily" }
entries << { loc: "#{base_url}/charts", changefreq: "daily" }
entries << { loc: "#{base_url}/nodes", changefreq: "daily" }
entries << { loc: "#{base_url}/federation", changefreq: "weekly" } if federation_enabled?
PotatoMesh::App::Pages.static_pages.each do |page|
next if page.noindex
next unless page.path
lastmod = begin
File.mtime(page.path).utc.strftime("%Y-%m-%d")
rescue SystemCallError
nil
end
entry = { loc: "#{base_url}/pages/#{page.slug}", changefreq: "weekly" }
entry[:lastmod] = lastmod if lastmod
entries << entry
end
entries
end
# Escape a string for inclusion as XML character data.
#
# Replaces the five XML predefined entities in a single pass.
# Used by the sitemap renderer instead of
# {Rack::Utils.escape_html} so apostrophes become the canonical
# +&apos;+ entity rather than an HTML-style numeric character
# reference.
#
# @param value [Object] input fragment; coerced to a string.
# @return [String] XML-safe representation.
def xml_escape(value)
value.to_s.gsub(XML_ESCAPE_PATTERN, XML_ESCAPE_REPLACEMENTS)
end
# Render a sitemap entry list as +urlset+ XML.
#
# @param entries [Array<Hash>] entries produced by
# {build_sitemap_entries}.
# @return [String] XML document body.
def render_sitemap_xml(entries)
lines = [%(<?xml version="1.0" encoding="UTF-8"?>),
%(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)]
entries.each do |entry|
lines << " <url>"
lines << " <loc>#{xml_escape(entry[:loc])}</loc>"
lines << " <lastmod>#{xml_escape(entry[:lastmod])}</lastmod>" if entry[:lastmod]
lines << " <changefreq>#{xml_escape(entry[:changefreq])}</changefreq>" if entry[:changefreq]
lines << " </url>"
end
lines << "</urlset>"
lines.join("\n") + "\n"
end
end
def self.registered(app)
@@ -160,6 +378,36 @@ module PotatoMesh
send_file path
end
app.get "/robots.txt" do
content_type "text/plain"
cache_control :public, max_age: 3600
build_robots_txt("#{public_base_url}/sitemap.xml")
end
app.get "/sitemap.xml" do
halt 404, "Not Found" if private_mode?
content_type "application/xml"
cache_control :public, max_age: 3600
render_sitemap_xml(build_sitemap_entries(public_base_url))
end
app.get "/og-image.png" do
override = string_or_nil(PotatoMesh::Config.og_image_url)
redirect override, 302 if override && override.match?(%r{\Ahttps?://}i)
begin
payload = PotatoMesh::OgImage.serve(base_url: public_base_url)
rescue PotatoMesh::OgImage::CaptureError
halt 503, "Preview unavailable"
end
content_type "image/png"
cache_control :public, max_age: payload[:max_age]
last_modified payload[:last_modified] if payload[:last_modified]
payload[:bytes]
end
app.get "/" do
render_root_view(:index, view_mode: :dashboard)
end
@@ -194,6 +442,7 @@ module PotatoMesh
render_root_view(
:page,
view_mode: :"page_#{slug}",
meta_overrides: static_page_meta_overrides(page),
extra_locals: {
page_title: page.title,
page_content_html: page_html,
@@ -215,6 +464,11 @@ module PotatoMesh
render_root_view(
:node_detail,
view_mode: :node_detail,
meta_overrides: node_detail_meta_overrides(
short_name: short_name,
long_name: long_name,
canonical_id: canonical_id,
),
extra_locals: {
node_reference_json: JSON.generate(reject_nil_values(reference_payload)),
node_page_short_name: short_name,
+86 -1
View File
@@ -47,6 +47,12 @@ module PotatoMesh
DEFAULT_FEDERATION_CRAWL_COOLDOWN_SECONDS = 300
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
DEFAULT_FEDERATION_SEED_DOMAINS = %w[potatomesh.net potatomesh.jmrp.io mesh.qrp.ro].freeze
DEFAULT_OG_IMAGE_TTL_SECONDS = 3_600
DEFAULT_OG_IMAGE_VIEWPORT_WIDTH = 1_200
DEFAULT_OG_IMAGE_VIEWPORT_HEIGHT = 630
DEFAULT_OG_IMAGE_NAVIGATION_TIMEOUT = 15
DEFAULT_OG_IMAGE_NETWORK_IDLE_DURATION = 1.5
DEFAULT_OG_IMAGE_NETWORK_IDLE_TIMEOUT = 8
# Retrieve the configured API token used for authenticated requests.
#
@@ -207,7 +213,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"0.6.2"
"0.6.3"
end
# Default refresh interval for frontend polling routines.
@@ -593,6 +599,85 @@ module PotatoMesh
fetch_string("CONNECTION", "/dev/ttyACM0")
end
# Optional absolute URL to use for the social share preview image.
#
# When set, the layout uses this URL verbatim for +og:image+ and
# +twitter:image+ and the runtime capture pipeline is skipped. Operators
# who do not want to ship Chromium in their container, or who prefer to
# host their own preview image on a CDN, can point at any reachable
# +https://+ URL.
#
# @return [String, nil] override URL or +nil+ when unset.
def og_image_url
fetch_string("OG_IMAGE_URL", nil)
end
# Cache lifetime for runtime-generated +/og-image.png+ responses, in
# seconds. Successful captures are stored on disk and reused until the
# TTL elapses; the next request after expiry refreshes the cache
# synchronously while holding a process-wide mutex so concurrent
# requesters serialise rather than spawning multiple browsers.
#
# @return [Integer] positive cache duration in seconds.
def og_image_ttl_seconds
fetch_positive_integer("OG_IMAGE_TTL_SECONDS", DEFAULT_OG_IMAGE_TTL_SECONDS)
end
# Viewport width used for the headless browser preview capture.
#
# @return [Integer] viewport width in CSS pixels.
def og_image_viewport_width
DEFAULT_OG_IMAGE_VIEWPORT_WIDTH
end
# Viewport height used for the headless browser preview capture.
#
# @return [Integer] viewport height in CSS pixels.
def og_image_viewport_height
DEFAULT_OG_IMAGE_VIEWPORT_HEIGHT
end
# Maximum time the headless browser may spend navigating to the
# capture target before the request is abandoned.
#
# @return [Integer] navigation timeout in seconds.
def og_image_navigation_timeout
DEFAULT_OG_IMAGE_NAVIGATION_TIMEOUT
end
# Continuous duration of network silence required before the screenshot
# is taken. Acts as a heuristic for "page settled".
#
# @return [Float] idle window duration in seconds.
def og_image_network_idle_duration
DEFAULT_OG_IMAGE_NETWORK_IDLE_DURATION
end
# Maximum time spent waiting for {og_image_network_idle_duration} of
# silence before the capture proceeds anyway.
#
# @return [Integer] idle wait ceiling in seconds.
def og_image_network_idle_timeout
DEFAULT_OG_IMAGE_NETWORK_IDLE_TIMEOUT
end
# Filesystem path used to cache the most recent runtime-generated
# preview image. The directory is created lazily on first capture.
#
# @return [String] absolute cache file path.
def og_image_cache_path
File.join(data_directory, "og-image.png")
end
# Filesystem path of the bundled fallback preview image served when no
# cached capture is available and the runtime generator is unable to
# produce one (e.g. Chromium missing, transient navigation failure).
#
# @return [String] absolute path to the packaged default PNG.
def og_image_default_path
File.join(web_root, "public", "og-image-default.png")
end
# Determine the best URL to represent the configured contact link.
#
# @return [String, nil] absolute URL when derivable, otherwise nil.
+141 -3
View File
@@ -66,17 +66,155 @@ module PotatoMesh
sentences.join(" ")
end
# Return the human-readable label associated with a logical view name.
#
# The label appears as the first segment of {.view_title} (e.g. the
# +"Map"+ portion of +"Map · PotatoMesh"+) and is omitted for views that
# should reuse the bare site name (such as the dashboard or detail pages
# whose title is built from per-record data).
#
# @param view [Symbol, String, nil] logical view identifier.
# @return [String, nil] navigation label or +nil+ when no label applies.
def view_label(view)
return nil if view.nil?
symbol = view.respond_to?(:to_sym) ? view.to_sym : view
{
map: "Map",
chat: "Chat",
charts: "Charts",
nodes: "Nodes",
federation: "Federation",
}[symbol]
end
# Compose the per-view document title using the +"Label · Site"+ pattern.
#
# @param view [Symbol, String, nil] logical view identifier.
# @param site [String] sanitized site name suffix.
# @return [String, nil] composed title or +nil+ when no view-specific
# label exists for the supplied identifier.
def view_title(view, site)
label = view_label(view)
return nil unless label
return label if site.nil? || site.empty?
"#{label} · #{site}"
end
# Build the per-view description string used for the +<meta name="description">+
# and Open Graph descriptions.
#
# @param view [Symbol, String, nil] logical view identifier.
# @param private_mode [Boolean] whether private mode is enabled. Drives
# suppression of chat-specific copy and other federation-aware text.
# @return [String, nil] description text or +nil+ when the view should
# inherit the global description.
def view_description(view, private_mode:)
return nil if view.nil?
symbol = view.respond_to?(:to_sym) ? view.to_sym : view
site = Sanitizer.sanitized_site_name
channel = Sanitizer.sanitized_channel
frequency = Sanitizer.sanitized_frequency
case symbol
when :map
map_view_description(site, channel, frequency)
when :chat
chat_view_description(site, channel, private_mode: private_mode)
when :charts
"Network activity charts for #{site}: nodes online, traffic, and signal quality."
when :nodes
"All Meshtastic and MeshCore nodes seen on #{site}, with last-heard time and metadata."
when :federation
"Federated PotatoMesh instances sharing node and message data with #{site}."
end
end
# Compose the description sentence used by the +/map+ view.
#
# @param site [String] sanitized site name.
# @param channel [String] sanitized channel label.
# @param frequency [String] sanitized frequency identifier.
# @return [String] descriptive sentence with the available channel and
# frequency suffixes.
def map_view_description(site, channel, frequency)
lead = "Live coverage map of #{site}"
lead += if !channel.empty? && !frequency.empty?
" on #{channel} (#{frequency})"
elsif !channel.empty?
" on #{channel}"
elsif !frequency.empty?
" tuned to #{frequency}"
else
""
end
"#{lead} — see node positions in real time."
end
# Compose the description sentence used by the +/chat+ view.
#
# @param site [String] sanitized site name.
# @param channel [String] sanitized channel label.
# @param private_mode [Boolean] whether the instance is running in
# private mode; chat is hidden for private deployments.
# @return [String, nil] description copy or +nil+ when chat is disabled.
def chat_view_description(site, channel, private_mode:)
return nil if private_mode
if channel.empty?
"Recent mesh chat traffic on #{site}."
else
"Recent mesh chat traffic on #{channel} for #{site}."
end
end
# Build a hash of meta configuration values used by templating layers.
#
# @param private_mode [Boolean] whether private mode is enabled.
# @param view [Symbol, String, nil] logical view identifier used to derive
# per-page title and description copy. When +nil+, the dashboard
# defaults are returned.
# @param overrides [Hash, nil] explicit values that take precedence over
# both view-specific and global defaults. Recognised keys: +:title+,
# +:description+, +:image+, +:noindex+.
# @return [Hash] structured metadata for templates.
def configuration(private_mode:)
def configuration(private_mode:, view: nil, overrides: nil)
site = Sanitizer.sanitized_site_name
base_description = description(private_mode: private_mode)
override_hash = overrides.is_a?(Hash) ? overrides : {}
override_title = string_or_nil(override_hash[:title])
override_description = string_or_nil(override_hash[:description])
override_image = string_or_nil(override_hash[:image])
override_noindex = override_hash[:noindex] == true
resolved_title = override_title || view_title(view, site) || site
resolved_description = override_description ||
view_description(view, private_mode: private_mode) ||
base_description
{
title: site,
title: resolved_title,
name: site,
description: description(private_mode: private_mode),
description: resolved_description,
image: override_image,
noindex: override_noindex,
}.freeze
end
# Coerce arbitrary input into a trimmed non-empty string or +nil+.
#
# @param value [Object, nil] candidate value.
# @return [String, nil] non-empty string or +nil+ when the input is
# blank, missing, or coerces to an empty value.
def string_or_nil(value)
return nil if value.nil?
str = value.is_a?(String) ? value : value.to_s
trimmed = str.strip
trimmed.empty? ? nil : trimmed
end
end
end
+364
View File
@@ -0,0 +1,364 @@
# 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 "fileutils"
require_relative "config"
require_relative "logging"
module PotatoMesh
# Runtime generator and cache layer for the Open Graph / Twitter Card
# preview image served at +/og-image.png+.
#
# The module is responsible for:
#
# * Producing a 1200×630 PNG screenshot of the dashboard via
# {Ferrum} (Chrome DevTools Protocol).
# * Caching successful captures on disk so that subsequent crawler hits
# are cheap.
# * Falling back to the previous cache (or the bundled default PNG) when
# a capture cannot be performed — for example, because Chromium is
# unavailable in the runtime image.
#
# The capture step is encapsulated in {.invoke_capture} so test suites
# can substitute it with {.capture_strategy=} and exercise the cache and
# response paths without launching a real browser.
module OgImage
module_function
# Raised when the capture pipeline could not produce a screenshot for
# any reason (Chromium missing, navigation timeout, transient network
# failure, etc.). Callers translate it into a fallback response.
class CaptureError < StandardError; end
# Minimum interval between capture attempts after a failure. Prevents a
# tight loop of relaunching Chromium when a persistent error is in play
# (e.g. disk-full breaks {.write_cache} or the browser binary is
# missing). Subsequent crawler hits inside the window are answered from
# the cache or the bundled default PNG without re-attempting.
CAPTURE_FAILURE_BACKOFF_SECONDS = 60
# Module-level mutex guarding capture invocations to prevent a
# thundering-herd of concurrent crawler requests from spawning multiple
# browsers. Created once when the module loads.
@capture_mutex = Mutex.new
# Optional override for the capture function. When set, {.invoke_capture}
# delegates to this callable instead of {.default_capture}; tests use
# this hook to inject deterministic byte payloads.
@capture_strategy = nil
# Timestamp of the last failed capture attempt. Used by
# {.in_failure_backoff?} to throttle retries when capture or cache
# writes are persistently failing.
@last_failure_at = nil
# Produce a response payload for the +/og-image.png+ route.
#
# @param base_url [String] absolute URL of the running application, used
# as the navigation target for the headless browser.
# @return [Hash] hash with +:bytes+ (binary PNG payload),
# +:last_modified+ ({Time}), and +:max_age+ (Integer seconds for the
# Cache-Control header).
def serve(base_url:)
bytes, last_modified = resolve_image_bytes(base_url: base_url)
{
bytes: bytes,
last_modified: last_modified,
max_age: PotatoMesh::Config.og_image_ttl_seconds,
}
end
# Resolve the freshest image bytes available, capturing a new
# screenshot when the cache is empty or stale.
#
# @param base_url [String] dashboard URL captured by Ferrum.
# @return [Array(String, Time)] PNG payload and its last-modified
# timestamp.
def resolve_image_bytes(base_url:)
cache = read_cache
return [cache[:bytes], cache[:mtime]] if cache && cache_fresh?(cache[:mtime])
refreshed = attempt_refresh(base_url)
return refreshed if refreshed
return [cache[:bytes], cache[:mtime]] if cache
default = read_default
return default if default
raise CaptureError, "no preview image available"
end
# Try to capture a fresh screenshot, returning the new payload on
# success and +nil+ when the capture failed, another thread is already
# running one, or the backoff window from a recent failure is still
# active.
#
# @param base_url [String] dashboard URL captured by Ferrum.
# @return [Array(String, Time), nil] new bytes and timestamp, or +nil+.
def attempt_refresh(base_url)
return nil if in_failure_backoff?
acquired = @capture_mutex.try_lock
return nil unless acquired
begin
bytes = invoke_capture(base_url)
write_succeeded = write_cache(bytes)
@last_failure_at = write_succeeded ? nil : Time.now
[bytes, Time.now]
rescue StandardError => e
log_capture_error(e)
@last_failure_at = Time.now
nil
ensure
@capture_mutex.unlock if acquired
end
end
# Determine whether a recent failure should suppress another capture
# attempt. The backoff is reset by the first successful capture and
# cache write.
#
# @return [Boolean] +true+ when capture attempts should be skipped.
def in_failure_backoff?
return false unless @last_failure_at
(Time.now - @last_failure_at) < CAPTURE_FAILURE_BACKOFF_SECONDS
end
# Invoke either the configured {.capture_strategy} or
# {.default_capture} to produce PNG bytes.
#
# @param base_url [String] navigation target.
# @return [String] binary PNG payload.
def invoke_capture(base_url)
strategy = @capture_strategy || method(:default_capture)
strategy.call(base_url)
end
# Default capture implementation backed by the +ferrum+ gem.
#
# The browser is launched with the configured viewport, navigated to
# +base_url+, and given a brief idle window before the screenshot is
# taken. Errors raised by Ferrum are wrapped in {CaptureError} so the
# serve path can fall back gracefully.
#
# @param base_url [String] navigation target.
# @return [String] binary PNG payload.
# @raise [CaptureError] when the capture cannot be performed.
def default_capture(base_url)
browser = build_browser
begin
browser.goto(base_url.to_s)
wait_for_settled(browser)
bytes = browser.screenshot(format: "png", encoding: :binary, full: false)
bytes.is_a?(String) ? bytes : bytes.to_s
ensure
safely_quit_browser(browser)
end
rescue LoadError => e
raise CaptureError, "ferrum not installed: #{e.message}"
rescue StandardError => e
raise CaptureError, "capture failed: #{e.message}"
end
# Construct a fresh Ferrum browser instance using configuration values.
# Loads the gem lazily so importing this module does not pull Chromium
# into environments that never need it.
#
# @return [Object] Ferrum::Browser instance.
def build_browser
require "ferrum"
Ferrum::Browser.new(browser_options)
end
# Build the option hash passed to +Ferrum::Browser.new+. Extracted as
# a separate method so tests can verify the dimensions without
# launching the browser.
#
# The +--no-sandbox+ flag is required to launch Chromium as a non-root
# user inside an Alpine container without the kernel SETUID helper.
# This is only safe because the capture target is always the
# operator's own dashboard ({.serve} fetches +base_url+ from the
# +/og-image.png+ route, which derives it from the running app's
# public URL). DO NOT extend this code path to capture untrusted URLs
# — the disabled sandbox would turn a renderer-process exploit into a
# container escape.
#
# +--disable-dev-shm-usage+ avoids /dev/shm OOMs in small containers
# and +--disable-gpu+ prevents WebGL probing on machines without a
# GPU. Both are routine for headless Chromium captures.
#
# @return [Hash] keyword options for Ferrum::Browser.
def browser_options
options = {
headless: true,
window_size: [
PotatoMesh::Config.og_image_viewport_width,
PotatoMesh::Config.og_image_viewport_height,
],
timeout: PotatoMesh::Config.og_image_navigation_timeout,
process_timeout: PotatoMesh::Config.og_image_navigation_timeout,
browser_options: {
"no-sandbox": nil,
"disable-dev-shm-usage": nil,
"disable-gpu": nil,
},
}
browser_path = ENV["FERRUM_BROWSER_PATH"]
options[:browser_path] = browser_path if browser_path && !browser_path.empty?
options
end
# Wait for the dashboard to reach a stable state before capturing.
# Network-idle timeouts are tolerated because some dashboard widgets
# may continue polling indefinitely.
#
# @param browser [Object] Ferrum::Browser instance.
# @return [void]
def wait_for_settled(browser)
return unless browser.respond_to?(:network)
browser.network.wait_for_idle(
duration: PotatoMesh::Config.og_image_network_idle_duration,
timeout: PotatoMesh::Config.og_image_network_idle_timeout,
)
rescue StandardError
# Idle timeout — proceed with a best-effort capture.
end
# Quit the browser, ignoring shutdown errors so a slow or already-dead
# browser does not mask the original exception.
#
# @param browser [Object, nil] Ferrum::Browser instance.
# @return [void]
def safely_quit_browser(browser)
return if browser.nil?
browser.quit
rescue StandardError
# Best-effort cleanup — never let teardown raise.
end
# Read the cached preview from disk when present and readable.
#
# @return [Hash{Symbol=>Object}, nil] hash with +:bytes+ and +:mtime+
# keys, or +nil+ when no cache file exists.
def read_cache
path = PotatoMesh::Config.og_image_cache_path
return nil unless File.file?(path) && File.readable?(path)
bytes = File.binread(path)
return nil if bytes.empty?
{ bytes: bytes, mtime: File.mtime(path) }
rescue SystemCallError
nil
end
# Persist the freshly-captured PNG payload to the cache location.
#
# Returns +true+ on success so callers can clear the failure backoff
# only when the cache is actually durable. Empty/nil payloads count as
# a write failure so the backoff path triggers and we do not loop
# capturing without persisting.
#
# @param bytes [String] binary PNG payload.
# @return [Boolean] +true+ on success, +false+ otherwise.
def write_cache(bytes)
return false unless bytes.is_a?(String) && !bytes.empty?
path = PotatoMesh::Config.og_image_cache_path
FileUtils.mkdir_p(File.dirname(path))
File.binwrite(path, bytes)
true
rescue SystemCallError => e
log_capture_error(e)
false
end
# Determine whether the cache mtime falls inside the configured TTL.
#
# @param mtime [Time] cache file modification time.
# @return [Boolean] +true+ when the cache is still fresh.
def cache_fresh?(mtime)
return false unless mtime.is_a?(Time)
(Time.now - mtime) < PotatoMesh::Config.og_image_ttl_seconds
end
# Read the bundled default PNG as a last-resort fallback.
#
# @return [Array(String, Time), nil] payload and modification time, or
# +nil+ when the default file is missing.
def read_default
path = PotatoMesh::Config.og_image_default_path
return nil unless File.file?(path) && File.readable?(path)
[File.binread(path), File.mtime(path)]
rescue SystemCallError
nil
end
# Override the capture strategy. Intended for test suites that need to
# exercise the serve/cache logic without spawning Chromium.
#
# @param callable [#call, nil] callable that accepts +base_url+ and
# returns PNG bytes, or +nil+ to restore {.default_capture}.
# @return [void]
def capture_strategy=(callable)
@capture_strategy = callable
end
# Reset module state for use in tests. Releases the capture mutex if
# it is held, clears the configured strategy, removes the cache file,
# and clears the failure backoff timestamp so individual specs are
# isolated from each other.
#
# @return [void]
def reset_for_tests!
@capture_strategy = nil
@last_failure_at = nil
@capture_mutex.unlock if @capture_mutex.owned?
path = PotatoMesh::Config.og_image_cache_path
File.unlink(path) if File.exist?(path)
rescue SystemCallError
# Cache cleanup is best-effort; ignore filesystem errors.
end
# Emit a structured warning when capture or cache I/O fails. Logging is
# best-effort: errors are swallowed when no logger is available so the
# serve path can continue to fall back without raising.
#
# @param error [Exception] caught error instance.
# @return [void]
def log_capture_error(error)
logger = PotatoMesh::Logging.logger_for
return unless logger
PotatoMesh::Logging.log(
logger,
:warn,
"preview capture fell back to cache/default",
context: "og_image",
error: error.class.name,
message: error.message,
)
end
end
end
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.6.1",
"version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.6.1",
"version": "0.6.3",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.6.2",
"version": "0.6.3",
"type": "module",
"private": true,
"scripts": {
+26
View File
@@ -1,3 +1,10 @@
---
title: About
description: Community dashboard for the local mesh — what it is, how to join, and where to read more.
# image: https://example.com/your-page-preview.png
# noindex: true
---
# About This Mesh
Welcome to this [PotatoMesh](https://github.com/l5yth/potato-mesh) instance - a community dashboard for off-grid mesh networks. This is an example page, please modify it before deploying.
@@ -39,6 +46,25 @@ Instance operators can add, edit, or remove pages by placing Markdown files in
the `pages/` directory (mounted as a Docker volume at `/app/pages`). Each file
becomes a new entry in the navigation bar.
### Optional Frontmatter
Each page may begin with a YAML frontmatter block to override the default
nav label and SEO meta tags. All keys are optional:
```
---
title: About
description: Short summary shown to search engines and link previews.
image: https://example.com/about-preview.png
noindex: true
---
```
- `title` — overrides the slug-derived nav label and the document title.
- `description` — replaces the global meta description for this page only.
- `image` — absolute URL to a per-page social preview image (1200×630 recommended).
- `noindex` — when truthy, emits `<meta name="robots" content="noindex,nofollow">` and removes the page from `/sitemap.xml`. Useful for legal pages such as Impressum that should remain reachable but not indexed.
### Filename Convention
```
@@ -0,0 +1,352 @@
/*
* 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.
*/
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
import { MINIMAL_CONFIG } from './main-app-test-helpers.js';
/**
* Build a minimal stub of the Leaflet ``L`` global that supports the surface
* area exercised during {@link initializeApp} setup and the subsequent
* {@link renderMap} render path. The stub is deliberately data-only — every
* Leaflet object is a plain ``{}`` shape with the methods the production code
* calls — so tests can introspect counts (e.g. how many markers were added
* to a particular layer) without depending on a real Leaflet runtime.
*
* The returned object also exposes the ``_recorded`` reference which holds
* arrays of created markers / lines / hubs so individual tests can assert on
* what was drawn into each layer. Layers themselves expose their internal
* ``_layers`` array, allowing direct assertions like
* ``stub.markersLayer._layers.length`` after a render. The stub is kept
* intentionally minimal — every method here corresponds to a call site in
* production main.js, so adding a new Leaflet call generally requires a
* matching entry here.
*
* @returns {Object} Stub Leaflet root with helper accessors.
*/
export function makeLeafletStub() {
const recorded = {
circleMarkers: [],
polylines: [],
markers: [],
divIcons: [],
layerGroups: [],
domEventStopPropagation: 0
};
/**
* Build a layer-group stub that records additions into an internal
* ``_layers`` array so tests can introspect what was drawn there.
*
* @returns {Object} Layer group stub.
*/
function makeLayerGroup() {
const group = {
_layers: [],
addTo() {
return group;
},
clearLayers() {
group._layers.length = 0;
return group;
}
};
recorded.layerGroups.push(group);
return group;
}
/**
* Construct a marker-shaped stub with the subset of Leaflet's marker API
* that production code interacts with. Used as the base for both
* ``L.circleMarker`` and ``L.marker`` results so the two share the
* ``addTo`` / ``on`` / ``getElement`` surface.
*
* @param {[number, number]} latLng Initial coordinate pair.
* @param {Object} [options] Marker options forwarded by the caller.
* @returns {Object} Marker stub.
*/
function makeMarker(latLng, options) {
const eventHandlers = new Map();
const marker = {
_latLng: latLng,
_addedTo: null,
options: options || {},
addTo(layer) {
marker._addedTo = layer;
if (layer && Array.isArray(layer._layers)) layer._layers.push(marker);
return marker;
},
on(event, handler) {
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
eventHandlers.get(event).push(handler);
return marker;
},
_eventHandlers: eventHandlers
};
return marker;
}
/**
* Construct a polyline-shaped stub for spider leader / neighbour /
* trace lines. Production code reads ``setLatLngs`` (used by the spider
* refresh helper) but never the getters, so we keep the shape minimal.
*
* @param {Array<[number, number]>} latLngs Initial coordinate list.
* @param {Object} [options] Polyline options.
* @returns {Object} Polyline stub.
*/
function makePolyline(latLngs, options) {
const line = {
_latLngs: latLngs,
_addedTo: null,
options: options || {},
addTo(layer) {
line._addedTo = layer;
if (layer && Array.isArray(layer._layers)) layer._layers.push(line);
return line;
}
};
return line;
}
/**
* Construct a tile-layer stub. ``initializeApp`` registers
* ``tileloadstart`` / ``tileload`` / ``load`` / ``tileerror`` handlers but
* never fires them in the test environment, so the stub just stores the
* registration for completeness.
*
* @param {string} url Tile URL template (ignored).
* @param {Object} [options] Tile options.
* @returns {Object} Tile-layer stub.
*/
function makeTileLayer(url, options) {
const tile = {
_url: url,
_events: new Map(),
options: options || {},
addTo() {
return tile;
},
on(event, handler) {
if (!tile._events.has(event)) tile._events.set(event, []);
tile._events.get(event).push(handler);
return tile;
}
};
return tile;
}
/**
* Construct the map stub returned by ``L.map()``. ``getZoom`` is
* mutable via ``_setZoom`` so individual tests can drive the dispatch
* branches without re-instantiating the entire harness.
*
* @returns {Object} Map stub.
*/
function makeMap() {
let zoom = 14;
const eventHandlers = new Map();
const map = {
_setZoom(value) {
zoom = value;
},
fitBounds() {
return map;
},
getZoom() {
return zoom;
},
latLngToLayerPoint(latLng) {
// Identity-ish: [lat, lon] → {x: lon, y: lat}. Keeps offsets simple
// to reason about in test assertions.
const lat = Array.isArray(latLng) ? latLng[0] : latLng.lat;
const lon = Array.isArray(latLng) ? latLng[1] : latLng.lng;
return { x: lon, y: lat };
},
layerPointToLatLng(point) {
return { lat: point.y, lng: point.x };
},
on(event, handler) {
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
eventHandlers.get(event).push(handler);
return map;
},
whenReady(cb) {
// Fire synchronously so the harness does not have to drive an event
// loop just to thread the ready-callback side effects.
if (typeof cb === 'function') cb();
return map;
},
invalidateSize() {
return map;
}
};
return map;
}
const stub = {
map(_container, _options) {
stub._map = makeMap();
return stub._map;
},
tileLayer: makeTileLayer,
layerGroup: makeLayerGroup,
circleMarker(latLng, options) {
const marker = makeMarker(latLng, options);
recorded.circleMarkers.push(marker);
return marker;
},
polyline(latLngs, options) {
const line = makePolyline(latLngs, options);
recorded.polylines.push(line);
return line;
},
marker(latLng, options) {
const marker = makeMarker(latLng, options);
recorded.markers.push(marker);
return marker;
},
divIcon(options) {
const icon = { options: options || {} };
recorded.divIcons.push(icon);
return icon;
},
point(x, y) {
return { x, y };
},
latLng(lat, lng) {
// ``L.latLng`` is invoked once during ``initializeApp`` to seed the
// initial map centre. The stub returns a plain object since the rest
// of the production code only reads ``.lat`` / ``.lng`` from it.
return { lat, lng };
},
DomEvent: {
stopPropagation() {
recorded.domEventStopPropagation += 1;
}
},
control(_options) {
// ``initializeApp`` calls ``L.control(...)`` to construct the legend
// toggle widget. The stub returns a chainable shape with ``addTo`` so
// the registration path completes without producing a real Leaflet
// control instance.
return {
addTo() {
return this;
}
};
},
_recorded: recorded
};
return stub;
}
/**
* Spin up the application with a Leaflet stub on ``window.L`` and a
* pre-registered ``#map`` element so the map-init branch of
* {@link initializeApp} runs to completion. Network ``fetch`` is replaced
* with a never-resolving promise so the trailing ``refresh()`` cycle does
* not race against the test's cleanup (the same pattern documented in the
* narrower ``stubFetchForApplyFilter`` helper).
*
* @param {Object} [opts]
* @param {Object} [opts.configOverrides] Per-test overrides merged into
* {@link MINIMAL_CONFIG}.
* @returns {{ testUtils: Object, env: Object, leaflet: Object, cleanup: Function }}
*/
export function setupAppWithLeaflet(opts = {}) {
const env = createDomEnvironment({ includeBody: true });
const mapContainer = env.createElement('div', 'map');
env.registerElement('map', mapContainer);
// ``applyFiltersToAllTiles`` writes to ``document.body.style`` via
// ``setProperty``; the bare ``MockElement`` only exposes an empty object,
// so extend it with the method. The ``style.cssText`` accumulator is
// diagnostic-only — production code never reads it back, but having it
// lets tests inspect what filters were applied if needed.
const bodyStyle = (env.window && env.window.document && env.window.document.body)
? env.window.document.body.style
: null;
if (bodyStyle && typeof bodyStyle.setProperty !== 'function') {
bodyStyle._properties = bodyStyle._properties || {};
bodyStyle.setProperty = (name, value) => {
bodyStyle._properties[name] = value;
};
}
// ``initializeApp`` calls ``window.matchMedia`` to set up a responsive
// legend listener. The base DOM mock does not provide it, so we install
// a no-op shim that returns a never-firing ``MediaQueryList`` shape.
if (env.window && typeof env.window.matchMedia !== 'function') {
env.window.matchMedia = () => ({
matches: false,
media: '',
addEventListener() {},
removeEventListener() {}
});
}
const previousWindowL = globalThis.window.L;
const previousGlobalL = globalThis.L;
const previousFetch = globalThis.fetch;
const leaflet = makeLeafletStub();
// Both ``window.L`` and the bare ``L`` global must be set: the
// ``hasLeaflet`` capture reads ``window.L``, while the runtime references
// ``L`` directly via the module's global scope. Mirror the way the
// browser's ``leaflet.js`` exposes the namespace.
globalThis.window.L = leaflet;
globalThis.L = leaflet;
// Pinning fetch to a never-resolving promise keeps any
// ``fetchActiveNodeStats`` / ``refresh`` chains from racing against the
// test cleanup. The promise never settles, so any future ``.then`` /
// ``.catch`` attached downstream simply hangs harmlessly until the next
// microtask cycle is abandoned by the test runner.
globalThis.fetch = () => new Promise(() => {});
const config = { ...MINIMAL_CONFIG, ...(opts.configOverrides || {}) };
const { _testUtils } = initializeApp(config);
return {
testUtils: _testUtils,
env,
leaflet,
cleanup() {
globalThis.fetch = previousFetch;
globalThis.window.L = previousWindowL;
globalThis.L = previousGlobalL;
env.cleanup();
}
};
}
/**
* Mirror of {@link withApp} that uses the Leaflet-aware setup. Ensures the
* cleanup runs regardless of test outcome.
*
* @param {function({ testUtils: Object, leaflet: Object, env: Object }): void} fn
* Test body.
* @param {Object} [opts] Forwarded to {@link setupAppWithLeaflet}.
*/
export function withAppAndLeaflet(fn, opts = {}) {
const harness = setupAppWithLeaflet(opts);
try {
fn({ testUtils: harness.testUtils, leaflet: harness.leaflet, env: harness.env });
} finally {
harness.cleanup();
}
}
@@ -18,6 +18,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { withApp } from './main-app-test-helpers.js';
import { withAppAndLeaflet } from './main-app-leaflet-stub.js';
/**
* Build a stub Leaflet ``L`` that implements ``point({x, y})``. The renderer
@@ -233,3 +234,600 @@ test('_setColocatedSpiderStateForTests returns the previous state and rejects no
assert.deepEqual(t._getColocatedSpiderStateForTests(), []);
});
});
/**
* Build a stub Leaflet map that reports a configurable zoom level. The
* other Leaflet-projection methods are kept identical to ``makeStubMap`` so
* the helper composes with existing harness shapes that exercise both
* ``getZoom`` and the projection.
*
* @param {number} zoom Zoom level to report from ``getZoom()``.
* @returns {Object} Stub map.
*/
function makeStubMapAtZoom(zoom) {
const base = makeStubMap();
base.getZoom = () => zoom;
return base;
}
test('currentZoomBucket returns "low" below the threshold and "high" at/above', () => {
withApp((t) => {
// No map injected → defensive default keeps the feature visible so the
// test harness behaves identically to today's no-Leaflet path.
assert.equal(t._currentZoomBucketForTests(), 'high');
t._setMapForTests(makeStubMapAtZoom(12));
assert.equal(t._currentZoomBucketForTests(), 'low');
t._setMapForTests(makeStubMapAtZoom(13));
assert.equal(t._currentZoomBucketForTests(), 'high');
t._setMapForTests(makeStubMapAtZoom(18));
assert.equal(t._currentZoomBucketForTests(), 'high');
// Non-finite zoom (e.g. before the projection is ready) must not flip
// the user into the low-zoom branch — fall back to 'high' so the
// current rendering remains usable.
t._setMapForTests(makeStubMapAtZoom(Number.NaN));
assert.equal(t._currentZoomBucketForTests(), 'high');
// Map without a getZoom method (e.g. a stub used purely for projection
// round-trips) is also treated as 'high' rather than throwing.
t._setMapForTests({});
assert.equal(t._currentZoomBucketForTests(), 'high');
t._setMapForTests(null);
});
});
test('handleZoomEndForColocatedHubs clears expanded keys when crossing the threshold', () => {
withApp((t) => {
// Pre-stage state as if the previous render was at high zoom with one
// group expanded; a zoomend that drops us below the threshold should
// erase that state. No fetch wrapper is needed because the new
// ``rerenderMapForFiltering`` helper called by the threshold-cross
// handler does not run the stats-fetch pipeline.
t._setLastRenderedZoomBucketForTests('high');
const seeded = new Set(['10.00000,20.00000']);
t._setExpandedColocatedKeysForTests(seeded);
t._setMapForTests(makeStubMapAtZoom(12));
t.handleZoomEndForColocatedHubs();
assert.equal(t._getExpandedColocatedKeysForTests().size, 0);
t._setMapForTests(null);
});
});
test('handleZoomEndForColocatedHubs leaves expanded keys alone when bucket is unchanged', () => {
withApp((t) => {
// Same bucket as the last render → no clear, no applyFilter side effect.
t._setLastRenderedZoomBucketForTests('high');
const seeded = new Set(['1.00000,2.00000']);
t._setExpandedColocatedKeysForTests(seeded);
t._setMapForTests(makeStubMapAtZoom(15));
t.handleZoomEndForColocatedHubs();
assert.equal(t._getExpandedColocatedKeysForTests(), seeded);
assert.ok(seeded.has('1.00000,2.00000'));
t._setMapForTests(null);
});
});
test('handleZoomEndForColocatedHubs handles zooming back up through the threshold', () => {
withApp((t) => {
// Previous render was low; zoom back up to high → expanded keys are
// (already) empty per the prior crossing, but the bucket flip must
// still register so subsequent clicks behave correctly.
t._setLastRenderedZoomBucketForTests('low');
t._setExpandedColocatedKeysForTests(new Set());
t._setMapForTests(makeStubMapAtZoom(14));
assert.doesNotThrow(() => t.handleZoomEndForColocatedHubs());
t._setMapForTests(null);
});
});
test('createColocatedHubMarker emits "*<count>" html and toggles expansion on click', () => {
withApp((t) => {
const previousL = globalThis.L;
const created = [];
let domEventStopCalls = 0;
let lastClickHandler = null;
globalThis.L = {
divIcon(opts) {
return { _kind: 'divIcon', options: opts };
},
marker(latLng, opts) {
const marker = {
latLng,
options: opts,
_addedTo: null,
on(event, handler) {
if (event === 'click') lastClickHandler = handler;
return marker;
},
addTo(layer) {
marker._addedTo = layer;
layer._children.push(marker);
return marker;
}
};
created.push(marker);
return marker;
},
DomEvent: {
stopPropagation() {
domEventStopCalls += 1;
}
}
};
const stubLayer = { _children: [] };
t._setColocatedHubsLayerForTests(stubLayer);
try {
// Reset the icon cache so this test's stub L is the source of every
// divIcon rather than a previous run's plain-object icon.
t._getColocatedHubIconCacheForTests().clear();
const result = t.createColocatedHubMarker('5.12345,6.54321', 4, 5.12345, 6.54321);
assert.equal(created.length, 1);
assert.equal(result, created[0]);
assert.deepEqual(result.latLng, [5.12345, 6.54321]);
// The divIcon receives the asterisk + count html and the spider hub
// class so the CSS rules in base.css can style it as a clickable badge.
const iconOptions = result.options.icon.options;
assert.equal(iconOptions.className, 'colocated-spider-hub');
assert.ok(/\*4</.test(iconOptions.html), `html ${iconOptions.html} should contain *4`);
assert.deepEqual(iconOptions.iconSize, [16, 16]);
assert.deepEqual(iconOptions.iconAnchor, [8, 8]);
// ``bubblingMouseEvents: false`` keeps Leaflet's internal event
// routing from forwarding the click to map-level handlers. The
// ``riseOnHover`` option is intentionally absent because divIcon
// markers handle z-index inconsistently across Leaflet versions.
assert.equal(result.options.bubblingMouseEvents, false);
assert.equal(result.options.riseOnHover, undefined);
// Marker was added to the injected hub layer rather than the global
// markers layer; this keeps hub badges in their own clearable group.
assert.equal(result._addedTo, stubLayer);
assert.equal(stubLayer._children.length, 1);
// Click → expandedColocatedKeys flips, both Leaflet's DomEvent
// helper and the raw DOM stopPropagation are invoked so the click
// is contained at every layer of the event pipeline.
let stopPropagationCalls = 0;
assert.ok(lastClickHandler);
lastClickHandler({
originalEvent: { stopPropagation() { stopPropagationCalls += 1; } }
});
assert.equal(stopPropagationCalls, 1);
assert.equal(domEventStopCalls, 1);
assert.ok(t._getExpandedColocatedKeysForTests().has('5.12345,6.54321'));
// Second click toggles back off.
lastClickHandler({
originalEvent: { stopPropagation() { stopPropagationCalls += 1; } }
});
assert.equal(stopPropagationCalls, 2);
assert.equal(domEventStopCalls, 2);
assert.equal(t._getExpandedColocatedKeysForTests().has('5.12345,6.54321'), false);
// A click without an originalEvent (or without stopPropagation) must
// still toggle without throwing — covers the defensive guard branch.
assert.doesNotThrow(() => lastClickHandler(undefined));
assert.ok(t._getExpandedColocatedKeysForTests().has('5.12345,6.54321'));
assert.doesNotThrow(() => lastClickHandler({ originalEvent: {} }));
} finally {
t._setColocatedHubsLayerForTests(null);
t._setExpandedColocatedKeysForTests(new Set());
t._getColocatedHubIconCacheForTests().clear();
globalThis.L = previousL;
}
});
});
test('_setExpandedColocatedKeysForTests round-trips and rejects non-Set input', () => {
withApp((t) => {
// Initial state from init: empty Set.
const initial = t._setExpandedColocatedKeysForTests(new Set(['a']));
assert.ok(initial instanceof Set);
assert.equal(initial.size, 0);
const live = t._getExpandedColocatedKeysForTests();
assert.ok(live.has('a'));
// Non-Set input replaces the live set with a fresh empty Set, returning
// the previous (now-stale) reference for the test to inspect.
const previous = t._setExpandedColocatedKeysForTests('not-a-set');
assert.equal(previous.size, 1);
assert.equal(t._getExpandedColocatedKeysForTests().size, 0);
});
});
test('_setColocatedHubsLayerForTests round-trips the hub layer reference', () => {
withApp((t) => {
const initial = t._setColocatedHubsLayerForTests('layer-a');
// Initial value is null because the harness never instantiates Leaflet.
assert.equal(initial, null);
assert.equal(t._getColocatedHubsLayerForTests(), 'layer-a');
const previous = t._setColocatedHubsLayerForTests(null);
assert.equal(previous, 'layer-a');
assert.equal(t._getColocatedHubsLayerForTests(), null);
});
});
test('_setLastRenderedZoomBucketForTests round-trips the bucket marker', () => {
withApp((t) => {
const initial = t._setLastRenderedZoomBucketForTests('high');
// Initial value is null because no render has yet captured a bucket.
assert.equal(initial, null);
assert.equal(t._getLastRenderedZoomBucketForTests(), 'high');
const previous = t._setLastRenderedZoomBucketForTests('low');
assert.equal(previous, 'high');
assert.equal(t._getLastRenderedZoomBucketForTests(), 'low');
});
});
/**
* Build a list of nodes that share an identical coordinate so the renderer
* can group them. Each node carries a unique ``node_id`` to satisfy the
* deterministic-slot ordering inside ``computeColocatedOffsets``.
*
* @param {number} count Number of nodes to generate.
* @param {number} [lat=50] Shared latitude.
* @param {number} [lon=10] Shared longitude.
* @param {Object} [extra] Optional extra fields merged into each node.
* @returns {Array<Object>} Nodes ready to feed into ``renderMap``.
*/
function makeColocatedNodes(count, lat = 50, lon = 10, extra = {}) {
const nodes = [];
for (let i = 0; i < count; i += 1) {
nodes.push({
node_id: `node-${i}`,
latitude: lat,
longitude: lon,
role: 'CLIENT',
protocol: 'meshtastic',
...extra
});
}
return nodes;
}
/**
* Count how many drawn objects in ``recorded`` ended up inside a particular
* layer group. ``recorded`` is the running history of every Leaflet object
* the stub created during the test, while ``layer._layers`` reflects only
* the ones still mounted (after ``clearLayers``). Filtering by both keeps
* the assertions stable across re-renders.
*
* @param {Array<Object>} recorded Array such as ``leaflet._recorded.circleMarkers``.
* @param {Object} layer Layer group whose ``_layers`` array tracks current mounts.
* @returns {number} Count of recorded items currently mounted on the layer.
*/
function countLayerMembers(recorded, layer) {
if (!layer || !Array.isArray(layer._layers)) return 0;
return recorded.filter(item => layer._layers.includes(item)).length;
}
test('renderMap renders flat overlap at zoom < COLOCATED_HUB_MIN_ZOOM', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(12);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
// Below the threshold every node renders as a normal circleMarker at
// its original coordinate; no hub badge is created and no leader lines
// are drawn. This is the "spider disabled" mode that the user asked
// for when the map is fully zoomed out.
assert.equal(hubLayer._layers.length, 0);
assert.equal(leaflet._recorded.markers.length, 0);
assert.equal(leaflet._recorded.circleMarkers.length, 3);
assert.equal(leaflet._recorded.polylines.length, 0);
// Markers stack at exactly the original coords (no projection round-trip).
for (const marker of leaflet._recorded.circleMarkers) {
assert.deepEqual(marker._latLng, [50, 10]);
}
// The cached zoom-bucket reflects what the render targeted, so the
// zoomend handler can detect a future bucket flip.
assert.equal(testUtils._getLastRenderedZoomBucketForTests(), 'low');
});
});
test('renderMap renders a collapsed hub at zoom ≥ COLOCATED_HUB_MIN_ZOOM', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
// Default state at high zoom is collapsed: a single hub badge replaces
// the three member markers, no leader lines are drawn, and the badge
// html carries the asterisk + count so the user can read the group
// size at a glance.
assert.equal(hubLayer._layers.length, 1);
assert.equal(leaflet._recorded.markers.length, 1);
assert.equal(leaflet._recorded.circleMarkers.length, 0);
assert.equal(leaflet._recorded.polylines.length, 0);
const hub = leaflet._recorded.markers[0];
assert.deepEqual(hub._latLng, [50, 10]);
assert.ok(/\*3</.test(hub.options.icon.options.html));
assert.equal(testUtils._getLastRenderedZoomBucketForTests(), 'high');
});
});
test('renderMap dedups the hub badge across the slots in a single group', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
// Five colocated nodes would yield five offset slots; the renderer must
// still create exactly one hub for the group rather than emitting one
// per slot. This exercises the ``renderedHubKeys`` dedup guard.
const nodes = makeColocatedNodes(5);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
assert.equal(hubLayer._layers.length, 1);
assert.equal(leaflet._recorded.markers.length, 1);
assert.ok(/\*5</.test(leaflet._recorded.markers[0].options.icon.options.html));
});
});
test('renderMap renders a singleton as a normal marker (no hub) at any zoom', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
const nodes = makeColocatedNodes(1, 1, 2);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
assert.equal(hubLayer._layers.length, 0);
assert.equal(leaflet._recorded.markers.length, 0);
assert.equal(leaflet._recorded.circleMarkers.length, 1);
assert.deepEqual(leaflet._recorded.circleMarkers[0]._latLng, [1, 2]);
});
});
test('renderMap fans out members and draws leader lines when a group is expanded', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
// Pre-stage the group as expanded so the renderer takes the (c) branch
// — the user already clicked the hub. The key matches the format
// ``computeColocatedOffsets`` produces at the default precision.
testUtils._setExpandedColocatedKeysForTests(new Set(['50.00000,10.00000']));
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
// Expanded mode: 1 hub still visible (the click affordance) + 3 member
// markers fanned out + 3 leader polylines.
assert.equal(hubLayer._layers.length, 1);
assert.equal(leaflet._recorded.markers.length, 1);
assert.equal(leaflet._recorded.circleMarkers.length, 3);
assert.equal(leaflet._recorded.polylines.length, 3);
// The spider state has one entry per fanned member so the zoomend hook
// can re-project them when the user keeps zooming.
assert.equal(testUtils._getColocatedSpiderStateForTests().length, 3);
});
});
test('renderMap prunes expandedColocatedKeys whose group has shrunk below 2', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
// Pre-stage a stale expansion key whose group will not exist in this
// render. After the render the key must be evicted so subsequent
// clicks at the same coordinate start collapsed.
testUtils._setExpandedColocatedKeysForTests(new Set(['99.00000,99.00000', '50.00000,10.00000']));
const nodes = makeColocatedNodes(1);
testUtils.renderMap(nodes, 0);
const live = testUtils._getExpandedColocatedKeysForTests();
assert.equal(live.has('99.00000,99.00000'), false, 'vanished group key was not pruned');
assert.equal(live.has('50.00000,10.00000'), false, 'shrunken group key was not pruned');
});
});
test('renderMap distance-filter regression: hub html reflects visible count', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
const nodes = makeColocatedNodes(4);
nodes[0].distance_km = 9999;
testUtils.renderMap(nodes, 0);
assert.equal(leaflet._recorded.markers.length, 1);
assert.ok(/\*3</.test(leaflet._recorded.markers[0].options.icon.options.html));
}, { configOverrides: { maxDistanceKm: 100 } });
});
test('renderMap re-renders preserve expansion across data refreshes', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
testUtils._setExpandedColocatedKeysForTests(new Set(['50.00000,10.00000']));
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
// First render produced 3 fanned markers; a second render with the
// same data must keep the expansion (i.e. re-emit 3 fanned markers
// rather than collapsing back to a hub-only state).
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
testUtils.renderMap(nodes, 0);
assert.equal(leaflet._recorded.circleMarkers.length, 3);
assert.equal(leaflet._recorded.markers.length, 1);
assert.equal(leaflet._recorded.polylines.length, 3);
assert.ok(testUtils._getExpandedColocatedKeysForTests().has('50.00000,10.00000'));
});
});
test('hub click invokes Leaflet stopPropagation through the live harness', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
const nodes = makeColocatedNodes(2);
testUtils.renderMap(nodes, 0);
// The hub badge created during renderMap is a regular Leaflet marker;
// its click handler should stop the event at both the Leaflet and DOM
// layers. Firing the registered click handler directly emulates a
// user click without needing a real DOM event.
const hub = leaflet._recorded.markers[0];
const handlers = hub._eventHandlers.get('click') || [];
assert.equal(handlers.length, 1);
const baselineDomEventCount = leaflet._recorded.domEventStopPropagation;
let stopPropagationCalls = 0;
handlers[0]({
originalEvent: { stopPropagation() { stopPropagationCalls += 1; } }
});
// The click handler must contain the event at both pipeline layers so
// the underlying overlayStack / map ``click`` handlers are not also
// notified. ``rerenderMapForFiltering`` then triggers a second
// renderMap cycle that re-evaluates the dispatch — but with the
// harness's empty ``allNodes`` the new render produces zero offsets,
// so the pruning step sees no surviving multi-node groups. We assert
// on the stopPropagation side effects rather than the post-render
// expansion state because the latter is correctly cleaned up by the
// pruning logic.
assert.equal(stopPropagationCalls, 1);
assert.equal(leaflet._recorded.domEventStopPropagation, baselineDomEventCount + 1);
});
});
test('hub click does not trigger an /api/stats fetch (surgical re-render)', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
const nodes = makeColocatedNodes(2);
testUtils.renderMap(nodes, 0);
// Replace the harness's never-resolving fetch with a counter so we can
// observe whether the click handler accidentally invokes it via the
// old ``applyFilter`` path. Capture the previous reference so the
// ``cleanup`` from withAppAndLeaflet can still restore it.
let fetchCalls = 0;
const previousFetch = globalThis.fetch;
globalThis.fetch = () => {
fetchCalls += 1;
return new Promise(() => {});
};
try {
const hub = leaflet._recorded.markers[0];
const handler = (hub._eventHandlers.get('click') || [])[0];
assert.ok(handler);
handler({ originalEvent: { stopPropagation() {} } });
// ``rerenderMapForFiltering`` only calls renderMap; the stats fetch
// that ``applyFilter`` used to issue should not have been triggered.
assert.equal(fetchCalls, 0);
} finally {
globalThis.fetch = previousFetch;
}
});
});
test('renderMap reuses a single divIcon instance across same-size groups', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
leaflet._recorded.divIcons.length = 0;
testUtils._getColocatedHubIconCacheForTests().clear();
// Two distinct groups of size 3 at different coordinates. The dispatch
// emits one hub per group, so this exercises the icon cache *within*
// a single render: the second hub should pick up the cached icon
// rather than allocating a new ``L.divIcon``.
const nodes = [
...makeColocatedNodes(3, 50, 10),
...makeColocatedNodes(3, 51, 11)
].map((n, i) => ({ ...n, node_id: `dup-${i}` }));
testUtils.renderMap(nodes, 0);
assert.equal(leaflet._recorded.markers.length, 2, 'expected one hub per group');
assert.equal(leaflet._recorded.divIcons.length, 1, 'expected exactly one divIcon allocation across both hubs');
assert.equal(
leaflet._recorded.markers[0].options.icon,
leaflet._recorded.markers[1].options.icon,
'both hubs should share the cached icon instance'
);
const cache = testUtils._getColocatedHubIconCacheForTests();
assert.equal(cache.size, 1);
assert.ok(cache.has(3));
});
});
test('renderMap reuses divIcons across re-renders', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
testUtils._getColocatedHubIconCacheForTests().clear();
const nodes = makeColocatedNodes(4);
testUtils.renderMap(nodes, 0);
const firstIcon = leaflet._recorded.markers[0].options.icon;
leaflet._recorded.markers.length = 0;
leaflet._recorded.divIcons.length = 0;
testUtils.renderMap(nodes, 0);
// Second render reuses the cached size-4 icon — no new divIcon
// allocation, and the new hub points at the same instance as before.
assert.equal(leaflet._recorded.divIcons.length, 0);
assert.equal(leaflet._recorded.markers[0].options.icon, firstIcon);
});
});
test('rerenderMapForFiltering refreshes the map without the applyFilter side effects', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
leaflet._recorded.divIcons.length = 0;
let fetchCalls = 0;
const previousFetch = globalThis.fetch;
globalThis.fetch = () => {
fetchCalls += 1;
return new Promise(() => {});
};
try {
// ``rerenderMapForFiltering`` reads ``allNodes`` directly; the test
// harness leaves it empty (no /api/nodes resolution), so the call
// exercises the early-return branches inside renderMap rather than
// a full render. The point of this test is the *absence* of side
// effects: no stats fetch, no thrown errors.
assert.doesNotThrow(() => testUtils.rerenderMapForFiltering());
assert.equal(fetchCalls, 0);
} finally {
globalThis.fetch = previousFetch;
}
});
});
test('_getColocatedHubIconCacheForTests exposes the live cache', () => {
// Use the Leaflet-aware harness so ``L.divIcon`` exists when the helper
// is invoked; the bare ``withApp`` harness leaves L undefined.
withAppAndLeaflet(({ testUtils }) => {
const cache = testUtils._getColocatedHubIconCacheForTests();
assert.ok(cache instanceof Map);
cache.clear();
assert.equal(cache.size, 0);
// Populating via ``getColocatedHubIcon`` proves the seam returns the
// same Map instance the production helper writes to.
const icon = testUtils.getColocatedHubIcon(7);
assert.equal(cache.get(7), icon);
assert.equal(testUtils.getColocatedHubIcon(7), icon, 'second lookup must hit the cache');
cache.clear();
});
});
test('renderMap places fanned markers around the shared centre when expanded', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
testUtils._setExpandedColocatedKeysForTests(new Set(['50.00000,10.00000']));
leaflet._recorded.circleMarkers.length = 0;
const nodes = makeColocatedNodes(2);
testUtils.renderMap(nodes, 0);
// The two fanned slots sit on opposite sides of the original centre at
// the configured base radius. The stub uses an identity projection
// ([lat, lon] → {x: lon, y: lat}), so the offset markers' coordinates
// differ from the centre by exactly ``baseRadiusPx`` (after the recent
// halving: 7px) along the X axis for the first slot.
assert.equal(leaflet._recorded.circleMarkers.length, 2);
// ``projectColocatedOffsetLatLng`` returns a ``[lat, lng]`` array, so
// each ``_latLng`` here is a tuple rather than a Leaflet LatLng object.
const offsets = leaflet._recorded.circleMarkers.map(m =>
Math.hypot(m._latLng[1] - 10, m._latLng[0] - 50)
);
for (const distance of offsets) {
assert.ok(distance > 0, `offset distance ${distance} should be > 0`);
assert.ok(Math.abs(distance - 7) < 1e-9, `offset distance ${distance} should match the halved base radius`);
}
});
});
@@ -235,6 +235,35 @@ test('entries without node_id still receive deterministic slots', () => {
}
});
test('grouped slots expose groupKey and groupSize', () => {
// Three colocated entries → every slot reports the same coordinate-derived
// bucket key (matching coordinateKey at the default precision) and the full
// membership count. This is what the renderer keys expand/collapse state
// off of, so a regression here would silently desync the hub interaction.
const entries = [
makeEntry('a', 10, 20),
makeEntry('b', 10, 20),
makeEntry('c', 10, 20)
];
const result = computeColocatedOffsets(entries);
const expectedKey = coordinateKey(10, 20, DEFAULT_PRECISION);
for (const slot of result) {
assert.equal(slot.groupKey, expectedKey);
assert.equal(slot.groupSize, 3);
}
});
test('singleton slots still report a stable groupKey and groupSize of 1', () => {
// Singletons need a groupKey too: the renderer treats every result row
// uniformly when pruning expand/collapse state, so even non-grouped points
// must carry a non-empty key matching their rounded coordinate.
const entries = [makeEntry('solo', 12.34567, 89.01234)];
const result = computeColocatedOffsets(entries);
assert.equal(result.length, 1);
assert.equal(result[0].groupSize, 1);
assert.equal(result[0].groupKey, coordinateKey(12.34567, 89.01234, DEFAULT_PRECISION));
});
test('coordinateKey formats lat/lon at requested precision', () => {
assert.equal(coordinateKey(1.234567, 7.654321, 3), '1.235,7.654');
assert.equal(coordinateKey(0, 0, DEFAULT_PRECISION), '0.00000,0.00000');
+324 -26
View File
@@ -509,6 +509,15 @@ export function initializeApp(config) {
const INITIAL_VIEW_PADDING_PX = 12;
const AUTO_FIT_PADDING_PX = 12;
const MAX_INITIAL_ZOOM = 13;
// Below this zoom level the co-located spider feature is disabled
// entirely: markers stack at their shared coordinate (no fan, no leader
// lines, no hub badge). At or above it, multi-node groups collapse into
// a single hub badge that the user can click to expand into the spider.
// Intentionally aligned with ``MAX_INITIAL_ZOOM`` above so the auto-fit
// initial view (which clamps at zoom 13) lands directly on the bucket
// boundary and users see the hub representation as soon as the map is
// ready rather than after their first zoom-in interaction.
const COLOCATED_HUB_MIN_ZOOM = 13;
let neighborLinesLayer = null;
let traceLinesLayer = null;
let neighborLinesVisible = true;
@@ -527,6 +536,27 @@ export function initializeApp(config) {
// into a single refresh; reset to ``null`` once the scheduled callback
// runs so the next frame can schedule again.
let pendingSpiderRefreshHandle = null;
// Leaflet layer that holds the small "asterisk + count" hub badges that
// collapse co-located groups at zoom levels at or above
// ``COLOCATED_HUB_MIN_ZOOM``. Initialised alongside the other map layers
// and cleared on every render before being re-populated.
let colocatedHubsLayer = null;
// Bucket keys (as returned by ``computeColocatedOffsets``) for groups the
// user has explicitly clicked open. Hubs whose key is in the set render
// their members fanned out + leader lines; absent keys render the hub
// alone. Cleared whenever the map crosses the zoom threshold so the
// collapsed default is restored when the visual context changes.
let expandedColocatedKeys = new Set();
// Tracks whether the most recent render was below or at/above the zoom
// threshold so the ``zoomend`` handler can detect threshold crossings and
// trigger a re-render that swaps the hub representation in or out.
let lastRenderedZoomBucket = null;
// Cache of divIcon instances keyed by group size. Building an icon for
// every multi-node group on every render is expensive at scale (hundreds
// of nodes can produce dozens of hubs); since the icon's html only varies
// by groupSize we share a single instance across same-size groups and
// across renders. See ``getColocatedHubIcon`` for the lookup.
const colocatedHubIconCache = new Map();
let tileDomObserver = null;
const fullscreenChangeEvents = [
'fullscreenchange',
@@ -1332,6 +1362,10 @@ export function initializeApp(config) {
// but never sit on top of the marker glyphs themselves.
spiderLinesLayer = L.layerGroup().addTo(map);
markersLayer = L.layerGroup().addTo(map);
// Hub badges render on top of the marker glyphs so the click target is
// always reachable, even when a stale marker happens to share the exact
// pixel coordinate of the hub centre.
colocatedHubsLayer = L.layerGroup().addTo(map);
// Pixel-space offsets are baked into a LatLng at render time, so the
// on-screen spread would otherwise scale with zoom — at extreme zoom-outs
@@ -1341,9 +1375,11 @@ export function initializeApp(config) {
// through `requestAnimationFrame` to coalesce redundant updates into a
// single redraw per frame; `zoomend` snaps to the final position; and
// `viewreset` covers projection resets such as resize / fullscreen /
// dateline wrap.
// dateline wrap. ``zoomend`` additionally watches for crossings of
// ``COLOCATED_HUB_MIN_ZOOM`` and re-runs ``applyFilter`` so the marker
// representation switches between flat / hub modes.
map.on('zoom', scheduleColocatedSpiderRefresh);
map.on('zoomend', refreshColocatedSpiderState);
map.on('zoomend', handleZoomEndForColocatedHubs);
map.on('viewreset', refreshColocatedSpiderState);
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
@@ -4077,6 +4113,138 @@ export function initializeApp(config) {
});
}
/**
* Classify the current zoom level relative to ``COLOCATED_HUB_MIN_ZOOM``.
*
* Returns ``'low'`` when the user is zoomed out far enough that the
* collapsed-hub representation should not be drawn (markers stack at the
* shared coordinate instead) and ``'high'`` otherwise. Defaults to
* ``'high'`` when the map is missing or its ``getZoom`` returns a
* non-finite value, which preserves the pre-feature behaviour during
* early init / tests where the projection is not yet available.
*
* @returns {'low'|'high'} Bucket name for the current zoom level.
*/
function currentZoomBucket() {
if (!map || typeof map.getZoom !== 'function') return 'high';
const zoom = map.getZoom();
if (!Number.isFinite(zoom)) return 'high';
return zoom < COLOCATED_HUB_MIN_ZOOM ? 'low' : 'high';
}
/**
* Wired to the map's ``zoomend`` event in addition to the spider
* re-projection. When the user crosses the
* ``COLOCATED_HUB_MIN_ZOOM`` threshold in either direction we forget the
* previously-expanded hub state and trigger a full re-render through
* {@link applyFilter}, since the marker representation switches between
* "flat overlap" and "hub badge" modes.
*
* @returns {void}
*/
function handleZoomEndForColocatedHubs() {
refreshColocatedSpiderState();
const bucket = currentZoomBucket();
if (bucket !== lastRenderedZoomBucket) {
expandedColocatedKeys.clear();
// Bucket flips only swap the marker representation; the node table,
// chat log, and active-stats counts are unaffected, so we re-render
// just the map rather than running the full applyFilter pipeline.
rerenderMapForFiltering();
}
}
/**
* Build the small "asterisk + count" hub badge that represents a collapsed
* (or expanded-but-still-visible) co-located group. The badge is a
* Leaflet ``L.marker`` backed by an ``L.divIcon`` so the visual is HTML/CSS
* (themable via ``var(--fg)`` / ``var(--bg)``) rather than the SVG
* ``L.circleMarker`` used for node points.
*
* Clicking the hub toggles ``expandedColocatedKeys`` for ``groupKey`` and
* triggers a full re-render via {@link applyFilter}. The hub deliberately
* does NOT participate in the node-info overlay — it is a control rather
* than a node anchor — so the click handler stops propagation to keep the
* ``overlayStack`` close path from also firing.
*
* @param {string} groupKey Bucket key from {@link computeColocatedOffsets}.
* @param {number} groupSize Number of (visible) nodes in the group.
* @param {number} lat Latitude of the shared centre.
* @param {number} lon Longitude of the shared centre.
* @returns {Object} The created Leaflet marker, already added to the layer.
*/
function createColocatedHubMarker(groupKey, groupSize, lat, lon) {
// ``bubblingMouseEvents: false`` keeps Leaflet's internal event system
// from forwarding the click to the map and any registered map-level
// ``click`` handlers (e.g. overlay close). ``riseOnHover`` is omitted
// intentionally — it is documented for the default raster icon's
// z-index handling and behaves inconsistently with ``divIcon`` across
// Leaflet versions; layer ordering (``colocatedHubsLayer`` is added
// *after* ``markersLayer``) keeps the hub on top reliably.
const marker = L.marker([lat, lon], {
icon: getColocatedHubIcon(groupSize),
keyboard: false,
bubblingMouseEvents: false
});
marker.on('click', event => {
// Stop the Leaflet event from bubbling to the map's own click handlers
// and stop the raw DOM event so the ``overlayStack`` close path does
// not also fire. We use the Leaflet helper rather than only the raw
// DOM stopPropagation because Leaflet routes events through its own
// pipeline before the browser does.
if (event && L && L.DomEvent && typeof L.DomEvent.stopPropagation === 'function') {
L.DomEvent.stopPropagation(event);
}
if (event && event.originalEvent && typeof event.originalEvent.stopPropagation === 'function') {
event.originalEvent.stopPropagation();
}
if (expandedColocatedKeys.has(groupKey)) {
expandedColocatedKeys.delete(groupKey);
} else {
expandedColocatedKeys.add(groupKey);
}
// Surgical re-render: only the map's marker representation changed.
// The table, chat log, and active-stats counts stay valid, so we skip
// the full applyFilter pipeline (and its ``/api/stats`` fetch) — that
// saves a round-trip per click and keeps rapid expand/collapse cheap.
rerenderMapForFiltering();
});
marker.addTo(colocatedHubsLayer);
return marker;
}
/**
* Lookup or lazily create the divIcon used to render a hub badge for a
* group of ``groupSize`` co-located nodes. Icons are cached because
* Leaflet allows the same icon instance to be shared across markers, the
* underlying DOM element is cloned per marker, and ``L.divIcon`` itself
* is non-trivially expensive at the volumes this feature can produce
* (every render creates one icon per multi-node group). The cache is
* keyed by ``groupSize`` because the html string is the only thing that
* varies between groups; it is bounded in practice (typical group sizes
* are small single digits) so it never grows large enough to warrant
* eviction.
*
* Theme changes do not invalidate the cache: the icon's html only carries
* the static text label and class hooks; all colour / border styling
* comes from CSS variables that resolve at paint time.
*
* @param {number} groupSize Number of visible nodes in the group.
* @returns {Object} Cached or freshly-created Leaflet divIcon.
*/
function getColocatedHubIcon(groupSize) {
const cached = colocatedHubIconCache.get(groupSize);
if (cached) return cached;
const icon = L.divIcon({
html: '<span class="colocated-spider-hub__glyph">*' + groupSize + '</span>',
className: 'colocated-spider-hub',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
colocatedHubIconCache.set(groupSize, icon);
return icon;
}
/**
* Render the Leaflet map markers and neighbour connections.
*
@@ -4097,9 +4265,15 @@ export function initializeApp(config) {
if (spiderLinesLayer) {
spiderLinesLayer.clearLayers();
}
if (colocatedHubsLayer) {
colocatedHubsLayer.clearLayers();
}
// Drop the previous render's spider records before populating them again
// so the zoom handler does not try to reposition stale Leaflet objects.
colocatedSpiderState = [];
// Capture the zoom bucket the upcoming render targets so the zoomend
// handler can detect threshold crossings on the next zoom event.
lastRenderedZoomBucket = currentZoomBucket();
markersLayer.clearLayers();
const pts = [];
const nodesById = new Map();
@@ -4320,19 +4494,73 @@ export function initializeApp(config) {
});
const offsets = computeColocatedOffsets(renderableEntries);
for (const { entry, dx, dy } of offsets) {
// Build the set of bucket keys that currently host more than one visible
// node so we can drop stale entries from ``expandedColocatedKeys`` (a
// group that lost members to the distance filter, an upstream delete,
// etc.). Keys whose group has shrunk to a singleton are pruned here so
// the remaining slot renders as a normal marker rather than carrying an
// orphaned "expanded" flag.
const visibleMultiGroupKeys = new Set();
for (const slot of offsets) {
if (slot && slot.groupSize >= 2) visibleMultiGroupKeys.add(slot.groupKey);
}
// Snapshot the keys before mutating the live set: ``Set`` iteration
// during ``delete`` is technically safe per spec, but copying first
// makes the intent explicit and keeps the loop body straightforward
// for future maintainers.
for (const key of Array.from(expandedColocatedKeys)) {
if (!visibleMultiGroupKeys.has(key)) expandedColocatedKeys.delete(key);
}
const zoomBucket = currentZoomBucket();
// Each multi-node group emits a single hub badge. Track which keys we
// have already drawn so we create the hub once even though the offsets
// array yields one slot per member.
const renderedHubKeys = new Set();
for (const slot of offsets) {
const { entry, dx, dy, groupKey, groupSize } = slot;
const n = entry.node;
const { lat, lon } = entry;
const isMulti = groupSize >= 2;
const lowZoom = zoomBucket === 'low';
const isExpanded = isMulti && !lowZoom && expandedColocatedKeys.has(groupKey);
// Hub badges represent multi-node groups at zoom levels where the
// collapsed control is meaningful; below the threshold they would just
// sit in a sea of overlapping markers without conveying useful info.
const showHub = isMulti && !lowZoom;
// Singletons always render their marker; multi-node groups render
// member markers only when the user has expanded the hub (or when the
// zoom is below the threshold and we fall back to flat overlap).
const showMarker = !isMulti || lowZoom || isExpanded;
// Use the helper-level significance test (rather than strict !== 0)
// because trig at angles like π produces values around 1e-15 which
// would otherwise pass the strict check and cause us to draw
// zero-length spider lines.
const useOffset = isExpanded && isOffsetSignificant(dx, dy);
if (showHub && !renderedHubKeys.has(groupKey)) {
createColocatedHubMarker(groupKey, groupSize, lat, lon);
renderedHubKeys.add(groupKey);
}
// Auto-fit bounds always use the original coordinate so the
// collapse/expand state cannot widen or narrow the fit window. Push
// here even when the underlying marker is suppressed so a fully
// collapsed group still contributes to the bounds.
pts.push([lat, lon]);
if (!showMarker) {
continue;
}
// Translate the pixel-space offset into the LatLng to render at. The
// baked-in LatLng is correct for the current zoom only; the zoom event
// handlers re-project on zoom/zoomend/viewreset to keep the gap
// visually constant when the user changes zoom. Use the helper-level
// significance test (rather than strict !== 0) because trig at angles
// like π produces values around 1e-15 which would otherwise pass the
// strict check and cause us to draw zero-length spider lines.
const markerLatLng = projectColocatedOffsetLatLng(lat, lon, dx, dy);
const isOffset = isOffsetSignificant(dx, dy);
// visually constant when the user changes zoom.
const markerLatLng = useOffset ? projectColocatedOffsetLatLng(lat, lon, dx, dy) : [lat, lon];
const color = getRoleColor(n.role, n.protocol);
const marker = L.circleMarker(markerLatLng, {
@@ -4344,14 +4572,14 @@ export function initializeApp(config) {
opacity: 0.7
});
// Draw a faint dotted leader line from each co-located marker back to
// Draw a faint dotted leader line from each fanned-out marker back to
// the shared physical location so the spider hub is visually obvious.
// Singleton markers (no offset) get no line. Stroke colour, dash,
// weight and opacity all live in `.colocated-spider-line` so the line
// can pick up theme-aware tokens (var(--fg)) and stay legible on both
// light and dark basemaps without code changes here.
// Singleton / collapsed / low-zoom markers get no line. Stroke
// colour, dash, weight and opacity all live in `.colocated-spider-line`
// so the line can pick up theme-aware tokens (var(--fg)) and stay
// legible on both light and dark basemaps without code changes here.
let spiderLine = null;
if (isOffset && spiderLinesLayer) {
if (useOffset && spiderLinesLayer) {
spiderLine = L.polyline([[lat, lon], markerLatLng], {
interactive: false,
className: 'colocated-spider-line'
@@ -4362,14 +4590,12 @@ export function initializeApp(config) {
let markerToken = 0;
marker.addTo(markersLayer);
// Track every offset marker so the zoomend handler can reposition the
// marker + leader line in lock-step. Singletons skip the record since
// their position never changes between zooms.
if (isOffset) {
// marker + leader line in lock-step. Markers rendered at the shared
// centre (singletons / low-zoom overlap / collapsed-group fallback)
// skip the record since their position never changes between zooms.
if (useOffset) {
colocatedSpiderState.push({ marker, line: spiderLine, lat, lon, dx, dy });
}
// Use the original coordinates for fitBounds so sub-pixel display
// offsets cannot widen the auto-fit window.
pts.push([lat, lon]);
attachNodeInfoRefreshToMarker({
marker,
@@ -4505,6 +4731,37 @@ export function initializeApp(config) {
return adjusted;
}
/**
* Re-run the active text/role/protocol filter pipeline over ``allNodes``
* and return the nodes that should currently render on the map and table.
* Pulled out of {@link applyFilter} so the colocated-hub click handler and
* the zoom-bucket-crossing handler can call it without paying for the
* table re-render, chat-log re-render, or stats fetch — none of which are
* affected by either of those events.
*
* @returns {Array<Object>} Filtered + sorted node list.
*/
function getFilteredSortedNodes() {
const filterQuery = filterInput ? filterInput.value : '';
const q = normaliseChatFilterQuery(filterQuery);
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n) && matchesProtocolFilter(n));
return sortNodes(filteredNodes);
}
/**
* Re-render only the map markers (hub badges, member markers, leader
* lines) without touching the node table, chat log, page title, or the
* ``/api/stats`` fetch. Used for events that only affect the marker
* representation — currently the colocated-hub expand/collapse click and
* the zoom-bucket threshold crossing — so we avoid the full
* {@link applyFilter} pipeline that those events would otherwise trigger.
*
* @returns {void}
*/
function rerenderMapForFiltering() {
renderMap(getFilteredSortedNodes(), Date.now() / 1000);
}
/**
* Apply text and role filters to the node list and re-render outputs.
*
@@ -4513,14 +4770,10 @@ export function initializeApp(config) {
function applyFilter() {
updateFilterClearVisibility();
const filterQuery = filterInput ? filterInput.value : '';
// Normalise query so empty strings and whitespace-only input are treated
// identically and comparisons are case-insensitive.
const q = normaliseChatFilterQuery(filterQuery);
// Text and role filters apply only to the node table and map; the chat log
// always receives the full node collection so reply-thread lookups succeed
// even for nodes that are currently hidden by the active filter.
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n) && matchesProtocolFilter(n));
const sortedNodes = sortNodes(filteredNodes);
const sortedNodes = getFilteredSortedNodes();
const nowSec = Date.now()/1000;
renderTable(sortedNodes, nowSec);
renderMap(sortedNodes, nowSec);
@@ -4910,6 +5163,22 @@ export function initializeApp(config) {
refreshColocatedSpiderState,
/** rAF-throttled wrapper around the spider refresh. */
scheduleColocatedSpiderRefresh,
/** ``zoomend`` handler that also detects co-located zoom-bucket crossings. */
handleZoomEndForColocatedHubs,
/** Build the asterisk + count hub badge for a co-located group. */
createColocatedHubMarker,
/** Lazily look up or create the divIcon for a hub of a given size. */
getColocatedHubIcon,
/** Render the map (test use only). */
renderMap,
/** Re-render only the map (skips the table / chat log / stats pipeline). */
rerenderMapForFiltering,
/** Classify the current zoom level as ``'low'`` or ``'high'``. */
_currentZoomBucketForTests: currentZoomBucket,
/** Inspect the live divIcon cache (test use only). */
_getColocatedHubIconCacheForTests() {
return colocatedHubIconCache;
},
/** Replace the recorded spider state for tests; returns the previous value. */
_setColocatedSpiderStateForTests(next) {
const previous = colocatedSpiderState;
@@ -4920,6 +5189,35 @@ export function initializeApp(config) {
_getColocatedSpiderStateForTests() {
return colocatedSpiderState;
},
/** Replace the expanded-group key set for tests; returns the previous value. */
_setExpandedColocatedKeysForTests(next) {
const previous = expandedColocatedKeys;
expandedColocatedKeys = next instanceof Set ? next : new Set();
return previous;
},
/** Inspect the live expanded-group key set (test use only). */
_getExpandedColocatedKeysForTests() {
return expandedColocatedKeys;
},
/** Inject a stub hub layer for tests; returns the previous value. */
_setColocatedHubsLayerForTests(next) {
const previous = colocatedHubsLayer;
colocatedHubsLayer = next;
return previous;
},
/** Inspect the hub layer (test use only). */
_getColocatedHubsLayerForTests() {
return colocatedHubsLayer;
},
/** Read or override the cached zoom bucket from the previous render. */
_setLastRenderedZoomBucketForTests(next) {
const previous = lastRenderedZoomBucket;
lastRenderedZoomBucket = next;
return previous;
},
_getLastRenderedZoomBucketForTests() {
return lastRenderedZoomBucket;
},
/** Inject a stub Leaflet map for tests that need to drive the projection. */
_setMapForTests(stub) {
const previous = map;
@@ -24,14 +24,15 @@ const DEFAULT_PRECISION = 5;
/**
* Default offset ring radius in pixels for a co-located group of two nodes.
* Chosen slightly larger than the standard map marker radius so the markers
* read as adjacent rather than overlapping. Callers may pass ``0`` to
* intentionally collapse all members of a group back onto the shared centre
* the value is honoured rather than substituted with the default so that
* the offset feature can be disabled without touching the call sites that
* still want grouping for other purposes.
* Sized so the ring sits inside the standard 9px-radius marker glyph rather
* than around it: the spider should read as a tight cluster rather than a
* fan that visually competes with the marker layer. Callers may pass ``0``
* to intentionally collapse all members of a group back onto the shared
* centre the value is honoured rather than substituted with the default
* so that the offset feature can be disabled without touching the call sites
* that still want grouping for other purposes.
*/
const DEFAULT_BASE_RADIUS_PX = 14;
const DEFAULT_BASE_RADIUS_PX = 7;
/**
* Tolerance (in pixels) below which an offset is considered effectively zero.
@@ -46,7 +47,7 @@ const OFFSET_EPSILON_PX = 1e-9;
* second. Keeps groups of five or more visually legible without growing
* unbounded for any single pair.
*/
const DEFAULT_RADIUS_GROWTH_PX = 4;
const DEFAULT_RADIUS_GROWTH_PX = 2;
/**
* Build a string key used to bucket entries that share the same coordinate at
@@ -156,13 +157,16 @@ export function buildRenderableEntries(nodes, options = {}) {
* @param {Object} [options] Optional tuning parameters.
* @param {number} [options.precision=5] Decimal places used to bucket nearby
* coordinates into the same group.
* @param {number} [options.baseRadiusPx=14] Pixel radius applied to a group
* @param {number} [options.baseRadiusPx=7] Pixel radius applied to a group
* of two nodes.
* @param {number} [options.radiusGrowthPx=4] Pixel radius added per extra
* @param {number} [options.radiusGrowthPx=2] Pixel radius added per extra
* node beyond the second.
* @returns {Array<{entry: {node: Object, lat: number, lon: number}, dx: number, dy: number}>}
* @returns {Array<{entry: {node: Object, lat: number, lon: number}, dx: number, dy: number, groupKey: string, groupSize: number}>}
* One result per input entry, in the original input order. Singleton
* groups receive ``{dx: 0, dy: 0}``.
* groups receive ``{dx: 0, dy: 0, groupSize: 1}``. ``groupKey`` is the
* stable bucket identifier returned by {@link coordinateKey} so callers
* can correlate slots that belong to the same co-located group across
* renders without re-running the bucketing themselves.
*/
export function computeColocatedOffsets(entries, options = {}) {
if (!Array.isArray(entries) || entries.length === 0) return [];
@@ -185,10 +189,11 @@ export function computeColocatedOffsets(entries, options = {}) {
});
const results = new Array(entries.length);
for (const bucket of groups.values()) {
if (bucket.length === 1) {
for (const [key, bucket] of groups.entries()) {
const groupSize = bucket.length;
if (groupSize === 1) {
const { entry, index } = bucket[0];
results[index] = { entry, dx: 0, dy: 0 };
results[index] = { entry, dx: 0, dy: 0, groupKey: key, groupSize: 1 };
continue;
}
@@ -212,7 +217,9 @@ export function computeColocatedOffsets(entries, options = {}) {
results[member.index] = {
entry: member.entry,
dx: radius * Math.cos(theta),
dy: radius * Math.sin(theta)
dy: radius * Math.sin(theta),
groupKey: key,
groupSize
};
});
}
+31
View File
@@ -145,6 +145,37 @@ tbody tr:nth-child(even) td {
pointer-events: none;
}
/*
* Asterisk + count badge that represents a collapsed (or expanded-but-still-
* present) co-located node group. The Leaflet ``L.divIcon`` host element
* receives the ``.colocated-spider-hub`` class; the inner ``__glyph`` span
* carries the visible circle so the cursor can still flip back to the
* default state outside the badge. Theming is delegated to the existing
* ``--fg`` / ``--bg`` tokens so the badge blends with both light and dark
* basemaps.
*/
.colocated-spider-hub {
cursor: pointer;
background: transparent;
border: 0;
}
.colocated-spider-hub__glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--fg);
font-size: 10px;
font-weight: 600;
line-height: 1;
user-select: none;
}
.leaflet-tooltip.trace-tooltip {
background: var(--bg2);
color: var(--fg);
Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

+4 -1
View File
@@ -1356,7 +1356,10 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include(%(meta name="description" content="#{expected_description}" />))
expect(last_response.body).to include('<meta property="og:title" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta property="og:site_name" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
expect(last_response.body).to include('<meta name="twitter:card" content="summary_large_image" />')
expect(last_response.body).to include('<meta name="twitter:image" content="http://spec.mesh.test/og-image.png" />')
expect(last_response.body).to include('<meta property="og:image:width" content="1200" />')
expect(last_response.body).to include('<meta property="og:image:height" content="630" />')
end
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
+205
View File
@@ -0,0 +1,205 @@
# 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::Routes::Root::Helpers do
let(:harness_class) do
Class.new do
include PotatoMesh::App::Helpers
include PotatoMesh::App::Routes::Root::Helpers
attr_accessor :request
def app_constant(name)
@constants ||= {}
@constants[name]
end
def set_constant(name, value)
@constants ||= {}
@constants[name] = value
end
end
end
let(:helper) { harness_class.new }
let(:request_double) { double("request", base_url: "http://upstream.example", scheme: "https") }
before do
helper.request = request_double
end
describe "#public_base_url" do
it "returns the instance domain when configured" do
helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net")
expect(helper.public_base_url).to eq("https://potatomesh.net")
end
it "honors the request scheme when present" do
helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net")
allow(request_double).to receive(:scheme).and_return("http")
expect(helper.public_base_url).to eq("http://potatomesh.net")
end
it "defaults to https when the scheme is missing" do
helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net")
allow(request_double).to receive(:scheme).and_return(nil)
expect(helper.public_base_url).to eq("https://potatomesh.net")
end
it "falls back to request.base_url when no instance domain is set" do
helper.set_constant(:INSTANCE_DOMAIN, nil)
expect(helper.public_base_url).to eq("http://upstream.example")
end
end
describe "#og_image_url" do
before do
helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net")
end
it "returns the OG_IMAGE_URL override verbatim when set" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("https://cdn.example.org/preview.png")
expect(helper.og_image_url).to eq("https://cdn.example.org/preview.png")
end
it "returns the runtime preview URL when no override is configured" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return(nil)
expect(helper.og_image_url).to eq("https://potatomesh.net/og-image.png")
end
it "treats blank overrides as unset" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return(" ")
expect(helper.og_image_url).to eq("https://potatomesh.net/og-image.png")
end
end
describe "#node_detail_title_label" do
it "combines short and long names when both are present" do
label = helper.node_detail_title_label(short_name: "ABCD", long_name: "Long Name", canonical_id: "!aabbccdd")
expect(label).to eq("ABCD (Long Name)")
end
it "returns the short name alone when long is missing" do
label = helper.node_detail_title_label(short_name: "ABCD", long_name: nil, canonical_id: "!aabbccdd")
expect(label).to eq("ABCD")
end
it "returns the long name when only that is present" do
label = helper.node_detail_title_label(short_name: nil, long_name: "Long", canonical_id: "!aabbccdd")
expect(label).to eq("Long")
end
it "falls back to the canonical id when both names are blank" do
label = helper.node_detail_title_label(short_name: nil, long_name: nil, canonical_id: "!aabbccdd")
expect(label).to eq("Node !aabbccdd")
end
it "uses a generic label when no identifier is available" do
label = helper.node_detail_title_label(short_name: nil, long_name: nil, canonical_id: nil)
expect(label).to eq("Node detail")
end
end
describe "#static_page_meta_overrides" do
let(:page) do
PotatoMesh::App::Pages::PageEntry.new(
slug: "about",
title: "About",
description: "Custom description.",
image: "https://e.com/p.png",
noindex: true,
)
end
before do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("Test Mesh")
end
it "includes only populated keys" do
result = helper.static_page_meta_overrides(page)
expect(result[:title]).to eq("About · Test Mesh")
expect(result[:description]).to eq("Custom description.")
expect(result[:image]).to eq("https://e.com/p.png")
expect(result[:noindex]).to be(true)
end
it "omits description, image, and noindex when frontmatter is empty" do
bare = PotatoMesh::App::Pages::PageEntry.new(slug: "about", title: "About")
result = helper.static_page_meta_overrides(bare)
expect(result.keys).to contain_exactly(:title)
expect(result[:title]).to eq("About · Test Mesh")
end
it "uses the bare title when the site name is blank" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("")
bare = PotatoMesh::App::Pages::PageEntry.new(slug: "about", title: "About")
result = helper.static_page_meta_overrides(bare)
expect(result[:title]).to eq("About")
end
it "falls back to the site name when title is blank" do
bare = PotatoMesh::App::Pages::PageEntry.new(slug: "about", title: "")
result = helper.static_page_meta_overrides(bare)
expect(result[:title]).to eq("Test Mesh")
end
end
describe "#xml_escape" do
it "escapes the five XML predefined entities" do
expect(helper.xml_escape("a&b<c>d\"e'f")).to eq("a&amp;b&lt;c&gt;d&quot;e&apos;f")
end
it "coerces non-string input into a string before escaping" do
expect(helper.xml_escape(42)).to eq("42")
end
end
describe "#build_robots_txt" do
it "returns a blanket disallow in private mode" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
result = helper.build_robots_txt("https://example.test/sitemap.xml")
expect(result).to eq("User-agent: *\nDisallow: /\n")
end
it "advertises the sitemap and instrumentation paths in public mode" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false)
result = helper.build_robots_txt("https://example.test/sitemap.xml")
expect(result).to include("Disallow: /metrics")
expect(result).to include("Disallow: /api/")
expect(result).to include("Sitemap: https://example.test/sitemap.xml")
end
end
end
+52
View File
@@ -114,6 +114,58 @@ RSpec.describe PotatoMesh::App::Identity do
end
end
describe ".log_instance_domain_resolution" do
let(:logger) { instance_double(Logger, debug: nil, warn: nil) }
before do
allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger)
end
around do |example|
original_app_env = ENV["APP_ENV"]
original_rack_env = ENV["RACK_ENV"]
example.run
ensure
if original_app_env
ENV["APP_ENV"] = original_app_env
else
ENV.delete("APP_ENV")
end
ENV["RACK_ENV"] = original_rack_env if original_rack_env
end
it "warns in production when the instance domain is unset" do
ENV["APP_ENV"] = "production"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :unconfigured)
PotatoMesh::Application.log_instance_domain_resolution
expect(logger).to have_received(:warn).with(/INSTANCE_DOMAIN is unset/)
end
it "stays quiet when the instance domain is configured" do
ENV["APP_ENV"] = "production"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "example.com")
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :env)
PotatoMesh::Application.log_instance_domain_resolution
expect(logger).not_to have_received(:warn)
end
it "stays quiet outside production even when the domain is unset" do
ENV["APP_ENV"] = "test"
ENV["RACK_ENV"] = "test"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :unconfigured)
PotatoMesh::Application.log_instance_domain_resolution
expect(logger).not_to have_received(:warn)
end
end
describe ".refresh_well_known_document_if_stale" do
let(:storage_dir) { Dir.mktmpdir }
let(:well_known_path) do
+239
View File
@@ -0,0 +1,239 @@
# 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::Meta do
before do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("Test Mesh")
allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("#TestCh")
allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("868MHz")
allow(PotatoMesh::Sanitizer).to receive(:sanitized_contact_link).and_return("#chat:example.org")
allow(PotatoMesh::Sanitizer).to receive(:sanitized_max_distance_km).and_return(10.0)
end
describe ".formatted_distance_km" do
it "drops trailing .0" do
expect(described_class.formatted_distance_km(42.0)).to eq("42")
end
it "preserves single-decimal precision" do
expect(described_class.formatted_distance_km(42.5)).to eq("42.5")
end
end
describe ".description" do
it "renders the standard description in public mode" do
result = described_class.description(private_mode: false)
expect(result).to include("Live Meshtastic mesh map for Test Mesh on #TestCh (868MHz).")
expect(result).to include("Track nodes, messages, and coverage in real time.")
expect(result).to include("within roughly 10 km")
expect(result).to include("Join the community in #chat:example.org via chat.")
end
it "omits message coverage in private mode" do
result = described_class.description(private_mode: true)
expect(result).to include("Track nodes and coverage in real time.")
expect(result).not_to include("messages,")
end
it "handles missing channel and frequency" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("")
allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("")
result = described_class.description(private_mode: false)
expect(result).to start_with("Live Meshtastic mesh map for Test Mesh.")
end
it "tunes the description when only frequency is configured" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("")
result = described_class.description(private_mode: false)
expect(result).to include("tuned to 868MHz")
end
it "describes the channel when only the channel is configured" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("")
result = described_class.description(private_mode: false)
expect(result).to include("on #TestCh")
end
it "skips the radius sentence when no max distance is configured" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_max_distance_km).and_return(nil)
result = described_class.description(private_mode: false)
expect(result).not_to include("within roughly")
end
it "skips the contact sentence when no contact is configured" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_contact_link).and_return(nil)
result = described_class.description(private_mode: false)
expect(result).not_to include("Join the community")
end
end
describe ".view_label" do
it "returns labels for known views" do
expect(described_class.view_label(:map)).to eq("Map")
expect(described_class.view_label(:chat)).to eq("Chat")
expect(described_class.view_label(:charts)).to eq("Charts")
expect(described_class.view_label(:nodes)).to eq("Nodes")
expect(described_class.view_label(:federation)).to eq("Federation")
end
it "accepts string view identifiers" do
expect(described_class.view_label("map")).to eq("Map")
end
it "returns nil for unknown views" do
expect(described_class.view_label(:dashboard)).to be_nil
expect(described_class.view_label(nil)).to be_nil
end
end
describe ".view_title" do
it "composes Label · Site for known views" do
expect(described_class.view_title(:map, "Test Mesh")).to eq("Map · Test Mesh")
end
it "returns nil when no label exists for the view" do
expect(described_class.view_title(:dashboard, "Test Mesh")).to be_nil
end
it "returns the bare label when site is blank" do
expect(described_class.view_title(:map, "")).to eq("Map")
end
end
describe ".view_description" do
it "renders the map description with channel and frequency" do
result = described_class.view_description(:map, private_mode: false)
expect(result).to include("Live coverage map of Test Mesh on #TestCh (868MHz)")
end
it "renders the map description with only channel" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("")
result = described_class.view_description(:map, private_mode: false)
expect(result).to include("on #TestCh")
end
it "renders the map description with only frequency" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("")
result = described_class.view_description(:map, private_mode: false)
expect(result).to include("tuned to 868MHz")
end
it "renders the bare map description without channel or frequency" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("")
allow(PotatoMesh::Sanitizer).to receive(:sanitized_frequency).and_return("")
result = described_class.view_description(:map, private_mode: false)
expect(result).to start_with("Live coverage map of Test Mesh —")
end
it "returns nil for the chat view in private mode" do
expect(described_class.view_description(:chat, private_mode: true)).to be_nil
end
it "returns chat description with channel" do
expect(described_class.view_description(:chat, private_mode: false)).to include("on #TestCh")
end
it "returns chat description without channel" do
allow(PotatoMesh::Sanitizer).to receive(:sanitized_channel).and_return("")
expect(described_class.view_description(:chat, private_mode: false)).to include("on Test Mesh")
end
it "returns descriptions for charts, nodes, and federation" do
expect(described_class.view_description(:charts, private_mode: false)).to include("Network activity charts for Test Mesh")
expect(described_class.view_description(:nodes, private_mode: false)).to include("All Meshtastic and MeshCore nodes seen on Test Mesh")
expect(described_class.view_description(:federation, private_mode: false)).to include("Federated PotatoMesh instances")
end
it "returns nil for unknown views" do
expect(described_class.view_description(:dashboard, private_mode: false)).to be_nil
expect(described_class.view_description(nil, private_mode: false)).to be_nil
end
end
describe ".configuration" do
it "returns the dashboard defaults when no view is supplied" do
result = described_class.configuration(private_mode: false)
expect(result[:title]).to eq("Test Mesh")
expect(result[:name]).to eq("Test Mesh")
expect(result[:description]).to include("Live Meshtastic mesh map for Test Mesh")
expect(result[:image]).to be_nil
expect(result[:noindex]).to be(false)
end
it "returns view-specific titles for known views" do
result = described_class.configuration(private_mode: false, view: :charts)
expect(result[:title]).to eq("Charts · Test Mesh")
expect(result[:description]).to include("Network activity charts")
end
it "honours overrides over view defaults" do
result = described_class.configuration(
private_mode: false,
view: :charts,
overrides: {
title: "Custom Title",
description: "Custom description",
image: "https://x/p.png",
noindex: true,
},
)
expect(result[:title]).to eq("Custom Title")
expect(result[:description]).to eq("Custom description")
expect(result[:image]).to eq("https://x/p.png")
expect(result[:noindex]).to be(true)
end
it "ignores blank overrides" do
result = described_class.configuration(
private_mode: false,
view: :charts,
overrides: { title: "", description: " " },
)
expect(result[:title]).to eq("Charts · Test Mesh")
expect(result[:description]).to include("Network activity charts")
end
it "treats a non-Hash overrides argument as nothing" do
result = described_class.configuration(private_mode: false, overrides: :not_a_hash)
expect(result[:title]).to eq("Test Mesh")
end
it "freezes the returned hash" do
result = described_class.configuration(private_mode: false)
expect(result).to be_frozen
end
end
describe ".string_or_nil" do
it "returns nil for nil" do
expect(described_class.string_or_nil(nil)).to be_nil
end
it "returns nil for blank strings" do
expect(described_class.string_or_nil(" ")).to be_nil
end
it "trims and returns non-blank strings" do
expect(described_class.string_or_nil(" hello ")).to eq("hello")
end
it "stringifies non-string input" do
expect(described_class.string_or_nil(42)).to eq("42")
end
end
end
+443
View File
@@ -0,0 +1,443 @@
# 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"
# Unit tests for the runtime Open Graph image module. The capture strategy
# is replaced with deterministic stubs so the full cache/fallback matrix
# can be exercised without spawning Chromium.
RSpec.describe PotatoMesh::OgImage do
let(:cache_path) { File.join(SPEC_TMPDIR, "og-cache-#{SecureRandom.hex(4)}.png") }
let(:default_path) { File.join(SPEC_TMPDIR, "og-default-#{SecureRandom.hex(4)}.png") }
before do
File.binwrite(default_path, "DEFAULT_BYTES")
allow(PotatoMesh::Config).to receive(:og_image_cache_path).and_return(cache_path)
allow(PotatoMesh::Config).to receive(:og_image_default_path).and_return(default_path)
described_class.reset_for_tests!
end
after do
described_class.reset_for_tests!
rescue StandardError
# Cleanup is best effort; some specs intentionally stub File ops.
ensure
begin
File.unlink(default_path) if File.exist?(default_path)
rescue StandardError
nil
end
begin
File.unlink(cache_path) if File.exist?(cache_path)
rescue StandardError
nil
end
end
describe ".serve" do
it "captures and caches a fresh image on first request" do
described_class.capture_strategy = ->(_) { "FRESH_BYTES" }
payload = described_class.serve(base_url: "http://localhost:41447")
expect(payload[:bytes]).to eq("FRESH_BYTES")
expect(payload[:max_age]).to eq(PotatoMesh::Config.og_image_ttl_seconds)
expect(File.binread(cache_path)).to eq("FRESH_BYTES")
end
it "passes the supplied base_url to the capture strategy" do
received = nil
described_class.capture_strategy = ->(url) { received = url; "BYTES" }
described_class.serve(base_url: "http://example.test")
expect(received).to eq("http://example.test")
end
it "returns the cached image while it remains fresh" do
File.binwrite(cache_path, "CACHED_BYTES")
described_class.capture_strategy = ->(_) { raise "should not be called" }
payload = described_class.serve(base_url: "http://localhost")
expect(payload[:bytes]).to eq("CACHED_BYTES")
end
it "refreshes when the cache is older than the TTL" do
File.binwrite(cache_path, "STALE_BYTES")
stale_time = Time.now - PotatoMesh::Config.og_image_ttl_seconds - 60
File.utime(stale_time, stale_time, cache_path)
described_class.capture_strategy = ->(_) { "REFRESHED_BYTES" }
payload = described_class.serve(base_url: "http://localhost")
expect(payload[:bytes]).to eq("REFRESHED_BYTES")
expect(File.binread(cache_path)).to eq("REFRESHED_BYTES")
end
it "falls back to the cached image when capture raises" do
File.binwrite(cache_path, "STALE_BYTES")
stale_time = Time.now - PotatoMesh::Config.og_image_ttl_seconds - 60
File.utime(stale_time, stale_time, cache_path)
described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "browser exploded" }
payload = described_class.serve(base_url: "http://localhost")
expect(payload[:bytes]).to eq("STALE_BYTES")
end
it "falls back to the default image when capture fails and no cache exists" do
described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" }
payload = described_class.serve(base_url: "http://localhost")
expect(payload[:bytes]).to eq("DEFAULT_BYTES")
end
it "raises CaptureError when neither capture nor default are available" do
described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" }
File.unlink(default_path)
expect { described_class.serve(base_url: "http://localhost") }.to raise_error(PotatoMesh::OgImage::CaptureError)
end
end
describe ".attempt_refresh" do
it "returns nil when the capture mutex is already held" do
held = Mutex.new
original = described_class.instance_variable_get(:@capture_mutex)
described_class.instance_variable_set(:@capture_mutex, held)
held.lock
begin
result = described_class.attempt_refresh("http://localhost")
expect(result).to be_nil
ensure
held.unlock
described_class.instance_variable_set(:@capture_mutex, original)
end
end
it "logs and returns nil when capture raises" do
logger = instance_double(Logger, warn: nil)
allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger)
described_class.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "oops" }
result = described_class.attempt_refresh("http://localhost")
expect(result).to be_nil
expect(PotatoMesh::Logging).to have_received(:logger_for).at_least(:once)
end
it "skips capture while the failure backoff window is active" do
described_class.instance_variable_set(:@last_failure_at, Time.now)
sentinel = ->(_) { raise "capture should not run during backoff" }
described_class.capture_strategy = sentinel
expect(described_class.attempt_refresh("http://localhost")).to be_nil
end
it "retries capture once the failure backoff window has elapsed" do
backoff = PotatoMesh::OgImage::CAPTURE_FAILURE_BACKOFF_SECONDS + 1
described_class.instance_variable_set(:@last_failure_at, Time.now - backoff)
described_class.capture_strategy = ->(_) { "RECOVERED" }
result = described_class.attempt_refresh("http://localhost")
expect(result).not_to be_nil
expect(result.first).to eq("RECOVERED")
expect(described_class.instance_variable_get(:@last_failure_at)).to be_nil
end
it "records a failure timestamp when the disk write fails" do
described_class.capture_strategy = ->(_) { "BYTES" }
allow(File).to receive(:binwrite).and_raise(Errno::ENOSPC)
result = described_class.attempt_refresh("http://localhost")
expect(result).not_to be_nil
expect(described_class.instance_variable_get(:@last_failure_at)).to be_a(Time)
end
end
describe ".in_failure_backoff?" do
it "is false when no failure has been recorded" do
described_class.instance_variable_set(:@last_failure_at, nil)
expect(described_class.in_failure_backoff?).to be(false)
end
it "is true while inside the backoff window" do
described_class.instance_variable_set(:@last_failure_at, Time.now)
expect(described_class.in_failure_backoff?).to be(true)
end
it "is false once the backoff window has elapsed" do
backoff = PotatoMesh::OgImage::CAPTURE_FAILURE_BACKOFF_SECONDS + 1
described_class.instance_variable_set(:@last_failure_at, Time.now - backoff)
expect(described_class.in_failure_backoff?).to be(false)
end
end
describe ".invoke_capture" do
it "delegates to the configured strategy" do
described_class.capture_strategy = ->(url) { "BYTES_FOR_#{url}" }
expect(described_class.invoke_capture("alpha")).to eq("BYTES_FOR_alpha")
end
it "falls back to default_capture when no strategy is configured" do
described_class.capture_strategy = nil
expect(described_class).to receive(:default_capture).with("alpha").and_return("DEF")
expect(described_class.invoke_capture("alpha")).to eq("DEF")
end
end
describe ".browser_options" do
it "honors the configured viewport dimensions" do
options = described_class.browser_options
expect(options[:window_size]).to eq([
PotatoMesh::Config.og_image_viewport_width,
PotatoMesh::Config.og_image_viewport_height,
])
expect(options[:headless]).to be true
end
# `--no-sandbox` is required for non-root Alpine containers; removing
# it would silently break Chromium launches in production. The
# corresponding assertion lives in security review (see comment in
# OgImage.browser_options).
it "passes the --no-sandbox flag" do
options = described_class.browser_options
expect(options[:browser_options]).to have_key(:"no-sandbox")
end
it "passes the --disable-dev-shm-usage flag" do
options = described_class.browser_options
expect(options[:browser_options]).to have_key(:"disable-dev-shm-usage")
end
it "passes the FERRUM_BROWSER_PATH env when present" do
original = ENV["FERRUM_BROWSER_PATH"]
ENV["FERRUM_BROWSER_PATH"] = "/custom/chromium"
begin
options = described_class.browser_options
expect(options[:browser_path]).to eq("/custom/chromium")
ensure
if original
ENV["FERRUM_BROWSER_PATH"] = original
else
ENV.delete("FERRUM_BROWSER_PATH")
end
end
end
it "omits browser_path when the env var is unset" do
original = ENV["FERRUM_BROWSER_PATH"]
ENV.delete("FERRUM_BROWSER_PATH")
begin
options = described_class.browser_options
expect(options).not_to have_key(:browser_path)
ensure
ENV["FERRUM_BROWSER_PATH"] = original if original
end
end
end
describe ".default_capture" do
it "wraps Ferrum errors in CaptureError" do
browser_double = double("browser")
allow(browser_double).to receive(:goto).and_raise(StandardError, "boom")
allow(browser_double).to receive(:quit)
allow(described_class).to receive(:build_browser).and_return(browser_double)
allow(described_class).to receive(:wait_for_settled)
expect { described_class.default_capture("http://localhost") }.to raise_error(PotatoMesh::OgImage::CaptureError, /capture failed/)
end
it "returns the screenshot bytes from the browser" do
browser_double = double("browser")
allow(browser_double).to receive(:goto)
allow(browser_double).to receive(:quit)
allow(browser_double).to receive(:screenshot).and_return("PNG")
allow(described_class).to receive(:build_browser).and_return(browser_double)
allow(described_class).to receive(:wait_for_settled)
expect(described_class.default_capture("http://localhost")).to eq("PNG")
end
it "wraps a missing ferrum gem in CaptureError" do
allow(described_class).to receive(:build_browser).and_raise(LoadError, "cannot load such file -- ferrum")
expect { described_class.default_capture("http://localhost") }.to raise_error(PotatoMesh::OgImage::CaptureError, /ferrum not installed/)
end
end
describe ".wait_for_settled" do
it "returns silently when the browser does not expose network" do
stub = double("browser")
allow(stub).to receive(:respond_to?).with(:network).and_return(false)
expect { described_class.wait_for_settled(stub) }.not_to raise_error
end
it "swallows idle timeouts" do
network = double("network")
allow(network).to receive(:wait_for_idle).and_raise(StandardError, "idle timeout")
stub = double("browser", network: network)
allow(stub).to receive(:respond_to?).with(:network).and_return(true)
expect { described_class.wait_for_settled(stub) }.not_to raise_error
end
end
describe ".safely_quit_browser" do
it "is a no-op when the browser is nil" do
expect { described_class.safely_quit_browser(nil) }.not_to raise_error
end
it "ignores errors raised during quit" do
stub = double("browser")
allow(stub).to receive(:quit).and_raise(StandardError, "already dead")
expect { described_class.safely_quit_browser(stub) }.not_to raise_error
end
end
describe ".cache_fresh?" do
it "returns false when the mtime is not a Time" do
expect(described_class.cache_fresh?(nil)).to be(false)
end
it "returns true for a recent mtime" do
expect(described_class.cache_fresh?(Time.now - 1)).to be(true)
end
it "returns false for an mtime older than the TTL" do
old = Time.now - PotatoMesh::Config.og_image_ttl_seconds - 1
expect(described_class.cache_fresh?(old)).to be(false)
end
end
describe ".read_cache" do
it "returns nil when the cache file does not exist" do
expect(described_class.read_cache).to be_nil
end
it "returns nil when the cache file is empty" do
File.binwrite(cache_path, "")
expect(described_class.read_cache).to be_nil
end
it "returns the bytes and mtime when the file exists" do
File.binwrite(cache_path, "BYTES")
result = described_class.read_cache
expect(result[:bytes]).to eq("BYTES")
expect(result[:mtime]).to be_a(Time)
end
it "returns nil on filesystem errors" do
File.binwrite(cache_path, "BYTES")
allow(File).to receive(:binread).with(cache_path).and_raise(Errno::EIO)
expect(described_class.read_cache).to be_nil
end
end
describe ".write_cache" do
it "returns false for empty input" do
expect(described_class.write_cache("")).to be(false)
expect(File.exist?(cache_path)).to be(false)
end
it "returns false for non-string input" do
expect(described_class.write_cache(nil)).to be(false)
expect(File.exist?(cache_path)).to be(false)
end
it "creates the cache directory when missing and returns true" do
nested_path = File.join(SPEC_TMPDIR, "og-nested-#{SecureRandom.hex(4)}", "img.png")
allow(PotatoMesh::Config).to receive(:og_image_cache_path).and_return(nested_path)
expect(described_class.write_cache("PAYLOAD")).to be(true)
expect(File.binread(nested_path)).to eq("PAYLOAD")
ensure
FileUtils.rm_rf(File.dirname(nested_path)) if nested_path
end
it "returns false and logs when the disk write fails" do
logger = instance_double(Logger, warn: nil)
allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger)
allow(File).to receive(:binwrite).and_raise(Errno::EIO)
expect(described_class.write_cache("DATA")).to be(false)
end
end
describe ".read_default" do
it "returns nil when the default file is missing" do
File.unlink(default_path)
expect(described_class.read_default).to be_nil
end
it "returns nil on filesystem errors" do
allow(File).to receive(:binread).with(default_path).and_raise(Errno::EIO)
expect(described_class.read_default).to be_nil
end
it "returns the bytes and mtime when present" do
bytes, mtime = described_class.read_default
expect(bytes).to eq("DEFAULT_BYTES")
expect(mtime).to be_a(Time)
end
end
describe ".log_capture_error" do
it "is a no-op when no logger is available" do
allow(PotatoMesh::Logging).to receive(:logger_for).and_return(nil)
expect { described_class.log_capture_error(StandardError.new("x")) }.not_to raise_error
end
it "delegates to the logging helper when a logger is configured" do
logger = instance_double(Logger, warn: nil)
allow(PotatoMesh::Logging).to receive(:logger_for).and_return(logger)
described_class.log_capture_error(StandardError.new("test"))
expect(logger).to have_received(:warn).at_least(:once)
end
end
describe ".reset_for_tests!" do
it "clears the cache file" do
File.binwrite(cache_path, "STALE")
described_class.reset_for_tests!
expect(File.exist?(cache_path)).to be(false)
end
it "ignores filesystem errors" do
File.binwrite(cache_path, "STALE")
allow(File).to receive(:unlink).and_call_original
allow(File).to receive(:unlink).with(cache_path).and_raise(Errno::EIO)
expect { described_class.reset_for_tests! }.not_to raise_error
end
end
end
+235
View File
@@ -382,6 +382,241 @@ RSpec.describe PotatoMesh::App::Pages do
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
+312
View File
@@ -0,0 +1,312 @@
# 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"
require "rexml/document"
# Acceptance suite for the search-engine and social-preview surfaces:
# +/robots.txt+, +/sitemap.xml+, per-route meta tags, the JSON-LD block on
# the dashboard, the Open Graph image override path, and the +noindex+
# frontmatter behaviour.
RSpec.describe "SEO surface" do
let(:app) { Sinatra::Application }
before do
PotatoMesh::App::Pages.clear_pages_cache!
PotatoMesh::OgImage.reset_for_tests!
PotatoMesh::OgImage.capture_strategy = ->(_) { "PNG_BYTES" }
end
after do
PotatoMesh::App::Pages.clear_pages_cache!
PotatoMesh::OgImage.reset_for_tests!
end
describe "GET /robots.txt" do
it "advertises the sitemap and disallows instrumentation in public mode" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false)
get "/robots.txt"
expect(last_response).to be_ok
expect(last_response.headers["Content-Type"]).to include("text/plain")
expect(last_response.body).to include("User-agent: *")
expect(last_response.body).to include("Disallow: /metrics")
expect(last_response.body).to include("Disallow: /api/")
expect(last_response.body).to include("Sitemap: http://spec.mesh.test/sitemap.xml")
end
it "blocks every path in private mode" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/robots.txt"
expect(last_response).to be_ok
expect(last_response.body).to include("User-agent: *")
expect(last_response.body).to include("Disallow: /")
expect(last_response.body).not_to include("Sitemap:")
end
it "sets a one-hour cache window" do
get "/robots.txt"
expect(last_response.headers["Cache-Control"]).to include("max-age=3600")
end
end
describe "GET /sitemap.xml" do
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-sitemap-#{SecureRandom.hex(4)}") }
before do
FileUtils.mkdir_p(pages_dir)
File.write(
File.join(pages_dir, "1-about.md"),
"---\ntitle: About\n---\n\n# About\n",
)
File.write(
File.join(pages_dir, "5-impressum.md"),
"---\ntitle: Impressum\nnoindex: true\n---\n\n# Impressum\n",
)
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
after do
FileUtils.rm_rf(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "returns well-formed XML listing the public dashboards" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false)
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/sitemap.xml"
expect(last_response).to be_ok
expect(last_response.headers["Content-Type"]).to include("application/xml")
doc = REXML::Document.new(last_response.body)
locs = REXML::XPath.match(doc, "//xmlns:loc", "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9")
.map(&:text)
expect(locs).to include("http://spec.mesh.test/")
expect(locs).to include("http://spec.mesh.test/map")
expect(locs).to include("http://spec.mesh.test/chat")
expect(locs).to include("http://spec.mesh.test/charts")
expect(locs).to include("http://spec.mesh.test/nodes")
expect(locs).to include("http://spec.mesh.test/federation")
expect(locs).to include("http://spec.mesh.test/pages/about")
end
it "omits the federation entry when federation is disabled" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false)
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
get "/sitemap.xml"
doc = REXML::Document.new(last_response.body)
locs = REXML::XPath.match(doc, "//xmlns:loc", "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9")
.map(&:text)
expect(locs).to include("http://spec.mesh.test/chat")
expect(locs).not_to include("http://spec.mesh.test/federation")
end
it "omits pages flagged with noindex frontmatter" do
get "/sitemap.xml"
expect(last_response.body).to include("/pages/about")
expect(last_response.body).not_to include("/pages/impressum")
end
it "omits lastmod for top-level routes but keeps it on pages" do
get "/sitemap.xml"
doc = REXML::Document.new(last_response.body)
ns = { "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9" }
url_nodes = REXML::XPath.match(doc, "//xmlns:url", ns)
page_entry = url_nodes.find do |node|
REXML::XPath.first(node, "xmlns:loc", ns)&.text == "http://spec.mesh.test/pages/about"
end
dashboard_entry = url_nodes.find do |node|
REXML::XPath.first(node, "xmlns:loc", ns)&.text == "http://spec.mesh.test/"
end
expect(REXML::XPath.first(page_entry, "xmlns:lastmod", ns)).not_to be_nil
expect(REXML::XPath.first(dashboard_entry, "xmlns:lastmod", ns)).to be_nil
end
it "returns 404 in private mode" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/sitemap.xml"
expect(last_response.status).to eq(404)
end
end
describe "per-route meta tags" do
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-meta-#{SecureRandom.hex(4)}") }
before do
FileUtils.mkdir_p(pages_dir)
File.write(
File.join(pages_dir, "1-about.md"),
"---\ntitle: About Us\ndescription: Custom about description for SEO.\n---\n\n# About\n",
)
File.write(
File.join(pages_dir, "2-impressum.md"),
"---\ntitle: Impressum\nnoindex: true\n---\n\n# Impressum\n",
)
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
after do
FileUtils.rm_rf(pages_dir)
PotatoMesh::App::Pages.clear_pages_cache!
end
it "uses a Map · Site title on the map view" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Test Mesh")
get "/map"
expect(last_response.body).to include("<title>Map · Test Mesh</title>")
end
it "uses a Charts · Site title on the charts view" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Test Mesh")
get "/charts"
expect(last_response.body).to include("<title>Charts · Test Mesh</title>")
end
it "honours frontmatter title and description on /pages/:slug" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Test Mesh")
get "/pages/about"
expect(last_response.body).to include("<title>About Us · Test Mesh</title>")
expect(last_response.body).to include('content="Custom about description for SEO."')
end
it "uses the page-level image: frontmatter for og:image and twitter:image" do
File.write(
File.join(pages_dir, "3-press.md"),
"---\ntitle: Press\nimage: https://cdn.example.org/press.png\n---\n\n# Press kit\n",
)
PotatoMesh::App::Pages.clear_pages_cache!
get "/pages/press"
expect(last_response.body).to include('<meta property="og:image" content="https://cdn.example.org/press.png" />')
expect(last_response.body).to include('<meta name="twitter:image" content="https://cdn.example.org/press.png" />')
expect(last_response.body).not_to include("og:image:width")
end
it "drops non-https image: frontmatter values" do
File.write(
File.join(pages_dir, "4-evil.md"),
"---\ntitle: Bad\nimage: javascript:alert(1)\n---\n\n# nope\n",
)
PotatoMesh::App::Pages.clear_pages_cache!
get "/pages/evil"
expect(last_response.body).not_to include("javascript:alert(1)")
expect(last_response.body).to include('<meta property="og:image" content="http://spec.mesh.test/og-image.png" />')
end
it "emits noindex meta when frontmatter requests it" do
get "/pages/impressum"
expect(last_response.body).to include('<meta name="robots" content="noindex,nofollow" />')
end
it "emits the JSON-LD WebSite schema only on the dashboard" do
get "/"
expect(last_response.body).to include('<script type="application/ld+json">')
expect(last_response.body).to include('"@type":"WebSite"')
get "/map"
expect(last_response.body).not_to include('<script type="application/ld+json">')
end
it "emits og:image dimensions only when serving the runtime PNG" do
get "/"
expect(last_response.body).to include('<meta property="og:image:width" content="1200" />')
expect(last_response.body).to include('<meta property="og:image:height" content="630" />')
expect(last_response.body).to include('<meta property="og:image:type" content="image/png" />')
end
it "omits og:image dimensions when OG_IMAGE_URL points at an external image" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("https://cdn.example.org/og.svg")
get "/"
expect(last_response.body).to include('<meta property="og:image" content="https://cdn.example.org/og.svg" />')
expect(last_response.body).not_to include("og:image:width")
expect(last_response.body).not_to include("og:image:height")
expect(last_response.body).not_to include("og:image:type")
end
end
describe "GET /og-image.png" do
it "returns the captured PNG bytes when the strategy succeeds" do
PotatoMesh::OgImage.capture_strategy = ->(_) { "FAKE_PNG_DATA" }
get "/og-image.png"
expect(last_response).to be_ok
expect(last_response.headers["Content-Type"]).to eq("image/png")
expect(last_response.body).to eq("FAKE_PNG_DATA")
end
it "redirects to the configured OG_IMAGE_URL override" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("https://cdn.example.org/og.png")
get "/og-image.png"
expect(last_response.status).to eq(302)
expect(last_response.headers["Location"]).to eq("https://cdn.example.org/og.png")
end
it "ignores OG_IMAGE_URL overrides that are not http(s)" do
allow(PotatoMesh::Config).to receive(:og_image_url).and_return("javascript:alert(1)")
get "/og-image.png"
expect(last_response).to be_ok
expect(last_response.headers["Content-Type"]).to eq("image/png")
end
it "falls back to the default PNG when capture fails and no cache exists" do
PotatoMesh::OgImage.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" }
get "/og-image.png"
expect(last_response).to be_ok
expect(last_response.body.bytesize).to eq(File.size(PotatoMesh::Config.og_image_default_path))
end
it "returns 503 when neither capture nor default are available" do
PotatoMesh::OgImage.capture_strategy = ->(_) { raise PotatoMesh::OgImage::CaptureError, "no chromium" }
allow(PotatoMesh::Config).to receive(:og_image_default_path).and_return("/nonexistent/og-image.png")
get "/og-image.png"
expect(last_response.status).to eq(503)
end
end
end
+32 -9
View File
@@ -23,31 +23,54 @@
<% meta_name_html = Rack::Utils.escape_html(meta_name) %>
<% meta_description_html = Rack::Utils.escape_html(meta_description) %>
<% request_path = request.path.to_s.empty? ? "/" : request.path %>
<% canonical_url = "#{request.base_url}#{request_path}" %>
<% canonical_base = (defined?(public_base_url) ? public_base_url : request.base_url) %>
<% canonical_url = "#{canonical_base}#{request_path}" %>
<% canonical_html = Rack::Utils.escape_html(canonical_url) %>
<% logo_url = "#{request.base_url}/potatomesh-logo.svg" %>
<% logo_url_html = Rack::Utils.escape_html(logo_url) %>
<% logo_alt_html = Rack::Utils.escape_html("#{meta_name} logo") %>
<% default_meta_image_url = "#{canonical_base}/og-image.png" %>
<% page_meta_image_url = (defined?(meta_image_url) && meta_image_url && !meta_image_url.empty?) ? meta_image_url : (defined?(og_image_url) ? og_image_url : default_meta_image_url) %>
<% page_meta_image_runtime = (page_meta_image_url == default_meta_image_url) %>
<% page_meta_image_html = Rack::Utils.escape_html(page_meta_image_url) %>
<% page_meta_image_alt_html = Rack::Utils.escape_html("#{meta_name} preview") %>
<% page_meta_noindex = defined?(meta_noindex) && meta_noindex %>
<title><%= meta_title_html %></title>
<meta name="application-name" content="<%= meta_name_html %>" />
<meta name="apple-mobile-web-app-title" content="<%= meta_name_html %>" />
<meta name="description" content="<%= meta_description_html %>" />
<% if page_meta_noindex %>
<meta name="robots" content="noindex,nofollow" />
<% end %>
<link rel="canonical" href="<%= canonical_html %>" />
<meta property="og:title" content="<%= meta_title_html %>" />
<meta property="og:site_name" content="<%= meta_name_html %>" />
<meta property="og:description" content="<%= meta_description_html %>" />
<meta property="og:type" content="website" />
<meta property="og:url" content="<%= canonical_html %>" />
<meta property="og:image" content="<%= logo_url_html %>" />
<meta property="og:image:alt" content="<%= logo_alt_html %>" />
<meta name="twitter:card" content="summary" />
<meta property="og:image" content="<%= page_meta_image_html %>" />
<meta property="og:image:alt" content="<%= page_meta_image_alt_html %>" />
<% if page_meta_image_runtime %>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/png" />
<% end %>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="<%= meta_title_html %>" />
<meta name="twitter:description" content="<%= meta_description_html %>" />
<meta name="twitter:image" content="<%= logo_url_html %>" />
<meta name="twitter:image:alt" content="<%= logo_alt_html %>" />
<meta name="twitter:image" content="<%= page_meta_image_html %>" />
<meta name="twitter:image:alt" content="<%= page_meta_image_alt_html %>" />
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
<link rel="icon" type="image/svg+xml" sizes="any" href="/potatomesh-logo.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<% if (defined?(current_view_mode) ? current_view_mode : nil).to_s == "dashboard" %>
<script type="application/ld+json">
<%= JSON.generate({
"@context" => "https://schema.org",
"@type" => "WebSite",
"name" => meta_name,
"url" => canonical_base,
"description" => meta_description,
}) %>
</script>
<% end %>
<link rel="stylesheet" href="/assets/styles/base.css" />
<script src="/assets/js/theme.js" defer></script>
<script src="/assets/js/background.js" defer></script>