mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-07 05:44:50 +02:00
Compare commits
6 Commits
v0.6.2
...
v0.6.3-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
| 09d9e7be13 | |||
| 43a5724b7f | |||
| c4dd825d72 | |||
| ee98efc120 | |||
| 521c2f2972 | |||
| f8b02b9f24 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+7
-7
@@ -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
@@ -14,7 +14,7 @@
|
||||
|
||||
[package]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+45
-29
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ~868–915) 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 ` ` 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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,18 @@ module PotatoMesh
|
||||
module Routes
|
||||
module Root
|
||||
module Helpers
|
||||
# Map of XML predefined entities used by {#xml_escape}.
|
||||
XML_ESCAPE_REPLACEMENTS = {
|
||||
"&" => "&",
|
||||
"<" => "<",
|
||||
">" => ">",
|
||||
'"' => """,
|
||||
"'" => "'",
|
||||
}.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
|
||||
# +'+ 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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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&b<c>d"e'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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user