Mobile tweaks and about page update, version bump to 0.1.1
This commit is contained in:
parent
4470b0b9c6
commit
6c29e4e1e7
7 changed files with 386 additions and 15 deletions
29
AGENTS.md
29
AGENTS.md
|
|
@ -250,6 +250,35 @@ Implementation notes:
|
|||
|
||||
The 24-entry Theme menu can exceed the viewport height, so `.menu-dropdown` itself carries `max-height` plus `overflow-y: auto`. This is the safe location for that property.
|
||||
|
||||
### Every screen needs a viewport meta, or the mobile CSS is dead
|
||||
|
||||
**`/about` shipped without `<meta name="viewport">` while every other screen had it.** Without it, mobile browsers fall back to a ~980px layout viewport and scale the page down. Two things follow, and both are invisible until you know to look:
|
||||
|
||||
1. **`@media (max-width: 600px)` never matches.** The query evaluates against the 980px layout viewport, not the real screen width.
|
||||
2. **The desktop layout is what renders**, scaled down — which reads as "assumes a desktop view."
|
||||
|
||||
The trap is that the CSS is *correct and present*. You can add a complete mobile stylesheet, see it inlined in the served page, confirm every rule and value is right, and the page still shows the desktop layout because the media query never fires. Two rounds of CSS work were spent on `/about` before the missing meta tag was spotted.
|
||||
|
||||
**When a screen's responsive CSS appears to do nothing, check the viewport meta before touching the CSS.**
|
||||
|
||||
`render_app`, `render_auth_page`, and `render_about` each render their own `<head>`, so a tag added to one is not inherited by the others. `every_screen_declares_a_viewport` in `tests/auth_gating.rs` checks `/` and `/about` per-screen for this reason; add new screens to that list.
|
||||
|
||||
### The About page is a separate document, not the app shell
|
||||
|
||||
`/about` renders its own `<html>` via `render_about` and inlines `style.css` itself. It reuses `render_menu_bar` and `render_status_bar` but has no `.toolbar` and no `.tab-bar`, so its chrome deliberately does not match the notes view.
|
||||
|
||||
**Its version and theme counts are derived, never typed.** The version comes from `env!("CARGO_PKG_VERSION")`, and the same macro drives the `.app-title` in the menu bar — which previously carried a hand-written `"v0.1"` that had already drifted from the crate's `0.1.0`. The theme line is `PREDEFINED_THEMES.len()` split by `Theme::is_dark()`, so adding or removing a palette needs no edit here. Both are pinned by `about_page_reports_the_manifest_version` and `about_page_theme_counts_come_from_the_palette`.
|
||||
|
||||
`Theme::is_dark` classifies on the local gamma-encoded `relative_luminance` rather than the linearised one in `syntax.rs`. That is safe *only* because the shipped palettes are strongly bimodal: the lightest dark background scores 0.095 and the darkest light one 0.63, so any threshold in that gap agrees. If a future palette lands near 0.5, switch this to the WCAG function rather than nudging the threshold — the two agree on all 24 themes today, so the current answer is not a close call.
|
||||
|
||||
**Every outbound link carries `rel="noopener noreferrer"`.** These are the only links that leave the machine, and the app advertises that it never talks to a third party. `about_page_external_links_are_hardened` walks every `<a href="https://` in the rendered page, so a link added later without the attributes fails rather than shipping quietly. It caught the Help menu's GPL link, which had `target="_blank"` and no `rel`.
|
||||
|
||||
**It is not missing a `.tab-bar` because that is a bug.** The tab bar exists to switch between the notes/editor/preview panes; the about page has no panes. Bolting it on would be chrome for its own sake. The menu bar *is* kept, because the app keeps it on mobile too.
|
||||
|
||||
The one thing it was genuinely missing was typography. It was the only screen with no rules of its own, so it rendered at UA defaults — a 2em heading with 0.67em margins and a 1.9 line-height feature list — which read as a desktop page on a phone. It now has a base `h1`/`p` treatment plus a `≤600px` override set (smaller heading and tagline, 1.6 line-height list, 20px padding, and a full-width 44px `.back-link` matching the HIG minimum the rest of the app enforces).
|
||||
|
||||
`render_about` must keep `include_str!("../static/style.css")` and the `class="back-link"` on the anchor. `about_page_inlines_the_stylesheet` and `about_page_back_link_is_styled` in `tests/auth_gating.rs` guard both — the latter because the mobile button style hangs entirely off that one class, so dropping it silently reverts to a 13px text link.
|
||||
|
||||
---
|
||||
|
||||
## 8. Menu Bar Behavior
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -386,7 +386,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "eternalibre-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "eternalibre-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
description = "EternaLibre Notes - a server-side rendered, copyleft Markdown notes application"
|
||||
|
|
|
|||
77
src/pages.rs
77
src/pages.rs
|
|
@ -195,11 +195,20 @@ fn render_menu_bar(base: &Base<'_>) -> Markup {
|
|||
span { "Help" }
|
||||
ul class="menu-dropdown" {
|
||||
li { a href=(base.url("/about")) { "About EternaLibre Notes" } }
|
||||
li { a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" { "GPL v3 License" } }
|
||||
li {
|
||||
a href="https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer" { "GPL v3 License" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
span class="app-title" { "EternaLibre Notes v0.1" }
|
||||
// From the manifest, so the title bar and the About page can
|
||||
// never disagree. This was a hardcoded "v0.1" that had already
|
||||
// drifted from the crate's 0.1.0.
|
||||
span class="app-title" {
|
||||
(format!("EternaLibre Notes v{}", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -422,11 +431,19 @@ pub fn render_auth_page(theme_name: &str, error: Option<&str>, base: &Base<'_>)
|
|||
|
||||
pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup {
|
||||
let theme = get_theme(theme_name);
|
||||
let themes = crate::themes::PREDEFINED_THEMES;
|
||||
let dark_themes = themes.iter().filter(|t| t.is_dark()).count();
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
// Without this the browser falls back to a ~980px layout
|
||||
// viewport and scales the page down, so every
|
||||
// `@media (max-width: 600px)` rule silently stops matching
|
||||
// and the desktop layout is what you get. This was the only
|
||||
// screen in the app missing the tag.
|
||||
meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover";
|
||||
title { "About - EternaLibre Notes" }
|
||||
style { (PreEscaped(theme.to_css_block())) }
|
||||
style { (PreEscaped(include_str!("../static/style.css"))) }
|
||||
|
|
@ -447,23 +464,61 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup {
|
|||
code from your authenticator app, and it never talks to a third party."
|
||||
}
|
||||
ul class="about-features" {
|
||||
// Counted from the palette itself. This number was
|
||||
// previously hardcoded and had drifted to "28" for
|
||||
// what was really 25 themes.
|
||||
// Counted from the palette itself, and split by
|
||||
// background luminance. This number used to be
|
||||
// hardcoded and had drifted to "28" for what was
|
||||
// really 25 themes.
|
||||
li {
|
||||
(format!("{} built-in themes with a full ANSI palette",
|
||||
crate::themes::PREDEFINED_THEMES.len()))
|
||||
(format!(
|
||||
"{} built-in themes ({} dark, {} light) with a full \
|
||||
ANSI palette — Solarized, Dracula, Nord, Monokai, and more",
|
||||
themes.len(),
|
||||
dark_themes,
|
||||
themes.len() - dark_themes,
|
||||
))
|
||||
}
|
||||
li { "Syntax highlighting for fenced code blocks" }
|
||||
li { "Notes stored as plain Markdown files on disk" }
|
||||
li { "TOTP-locked, with no accounts or third-party services" }
|
||||
li { "Note editing, search, and theming work without JavaScript" }
|
||||
}
|
||||
p {
|
||||
"Licensed under the GNU General Public License v3.0 or later. \
|
||||
No telemetry, no accounts, no vendor."
|
||||
h2 { "Author" }
|
||||
ul class="about-links" {
|
||||
li {
|
||||
a href="https://github.com/MARKMENTAL" rel="noopener noreferrer" {
|
||||
"GitHub — MARKMENTAL"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a href="https://mentalnet.xyz/forgejo-v2/" rel="noopener noreferrer" {
|
||||
"Forgejo — mentalnet.xyz"
|
||||
}
|
||||
}
|
||||
}
|
||||
a href=(base.url("/")) { "Back to notes" }
|
||||
h2 { "Other projects" }
|
||||
ul class="about-links" {
|
||||
li {
|
||||
a href="https://github.com/MARKMENTAL/tuxdock"
|
||||
rel="noopener noreferrer" {
|
||||
"tux-dock — a C++ TUI for managing Docker containers"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a href="https://github.com/MARKMENTAL/mentalnet-gnu-linux"
|
||||
rel="noopener noreferrer" {
|
||||
"mentalnet GNU/Linux — a TTY-only distribution for vintage \
|
||||
Pentium hardware"
|
||||
}
|
||||
}
|
||||
}
|
||||
p class="about-colophon" {
|
||||
(format!(
|
||||
"Version {}. Licensed under the GNU General Public \
|
||||
License v3.0 or later. No telemetry, no accounts, no vendor.",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
}
|
||||
a class="back-link" href=(base.url("/")) { "Back to notes" }
|
||||
}
|
||||
(render_status_bar(0, theme_name))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ impl Theme {
|
|||
)
|
||||
}
|
||||
|
||||
/// True when the background is dark enough that light text reads on it.
|
||||
///
|
||||
/// The shipped palettes are strongly bimodal: the lightest *dark*
|
||||
/// background scores 0.095 and the darkest *light* one scores 0.63, so
|
||||
/// any threshold in that gap returns the same answer. That margin is why
|
||||
/// this can use the local gamma-encoded `relative_luminance` rather than
|
||||
/// the linearised one in `syntax.rs` — for a binary split the two agree
|
||||
/// on all 24 themes, and adding a third implementation would cost more
|
||||
/// than the precision is worth.
|
||||
pub fn is_dark(&self) -> bool {
|
||||
relative_luminance(self.bg) < 0.5
|
||||
}
|
||||
|
||||
fn button_colors(&self, accent: usize) -> (&'static str, &'static str) {
|
||||
// Use the bright variant of the accent color if it has decent luminance;
|
||||
// otherwise fall back to the theme foreground color so text remains readable.
|
||||
|
|
|
|||
124
static/style.css
124
static/style.css
|
|
@ -86,7 +86,7 @@ button, input, select, textarea {
|
|||
top: calc(100% - 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
/* The Theme menu has 28 entries; cap the height so it cannot run off
|
||||
/* The Theme menu has 24 entries; cap the height so it cannot run off
|
||||
the bottom of the viewport (body has overflow: hidden). */
|
||||
max-height: calc(100dvh - 48px);
|
||||
overflow-y: auto;
|
||||
|
|
@ -705,6 +705,65 @@ button, input, select, textarea {
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The about page was the one screen in the app with no typography rules at
|
||||
all, so it rendered at UA defaults: a 2em heading with 0.67em margins and
|
||||
a 1.9 line-height feature list. That is what made it read as a desktop
|
||||
page on a phone. */
|
||||
.about-page main > h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 4px;
|
||||
/* Balances a wrapped title instead of leaving a ragged last line. */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.about-page main > p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.about-page main > h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.6;
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
|
||||
/* Outbound links. Kept as a list rather than prose so each destination reads
|
||||
as its own target, and so the mobile 44px touch-target rule can apply to
|
||||
the whole row. */
|
||||
.about-links {
|
||||
list-style: none;
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.about-links li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-links a {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
margin: 0 -12px;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.about-links a:hover {
|
||||
background-color: var(--ui-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Sits last and stays muted; it is a colophon, not a call to action. */
|
||||
.about-page .about-colophon {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin: 28px 0 20px;
|
||||
}
|
||||
|
||||
.about-page .tagline {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
|
@ -718,6 +777,23 @@ button, input, select, textarea {
|
|||
line-height: 1.9;
|
||||
}
|
||||
|
||||
/* "Back to notes" is the only way off this screen, so it gets a real target
|
||||
rather than bare link text. */
|
||||
.about-page .back-link {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.about-page .back-link:hover {
|
||||
background-color: var(--ui-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar — hidden on desktop */
|
||||
.tab-bar {
|
||||
display: none;
|
||||
|
|
@ -955,7 +1031,51 @@ button, input, select, textarea {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* About page. Without these it keeps the desktop reading measure: a 26px
|
||||
heading, an 18px tagline, and a 1.9 line-height list, all sized for a
|
||||
wide column rather than a phone. */
|
||||
.about-page main {
|
||||
padding: 24px 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.about-page main > h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.about-page .tagline {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.about-features {
|
||||
padding-left: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Apple HIG minimum touch target, matching `.btn` above. The negative
|
||||
margin is cancelled out so the hover background still bleeds to the
|
||||
padding edge without pushing the row off the column. */
|
||||
.about-links a {
|
||||
min-height: 44px;
|
||||
padding: 12px;
|
||||
margin: 0 -4px;
|
||||
font-size: 16px; /* prevents iOS zoom-on-focus */
|
||||
}
|
||||
|
||||
.about-page main > h2 {
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
|
||||
.about-page .about-colophon {
|
||||
font-size: 13px;
|
||||
margin: 24px 0 20px;
|
||||
}
|
||||
|
||||
/* Apple HIG minimum touch target, matching `.btn` above. */
|
||||
.about-page .back-link {
|
||||
display: block;
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -650,6 +650,160 @@ async fn theme_cookie_alongside_session_still_works() {
|
|||
assert!(String::from_utf8_lossy(&bytes).contains("data-theme=\"Dracula\""));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- about page ----
|
||||
|
||||
/// The about page's mobile styling hangs entirely off this one class.
|
||||
///
|
||||
/// `style.css` turns `.back-link` into a full-width 44px touch target on
|
||||
/// mobile, matching the HIG minimum the rest of the app enforces. Drop the
|
||||
/// class and the only way off the page silently reverts to a ~13px text
|
||||
/// link — which is exactly the "assumes a desktop view" problem it was
|
||||
/// added to fix. The heading rule is anchored on `main > h1` and so needs
|
||||
/// no class, but the link does.
|
||||
#[tokio::test]
|
||||
async fn about_page_back_link_is_styled() {
|
||||
let f = Fixture::fresh("about-link").login_over_http().await;
|
||||
let (status, body) = f.get_authed("/about").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body.contains(r#"class="back-link""#),
|
||||
"about page is missing the back-link class that style.css targets"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Back to notes"),
|
||||
"about page should still offer a way back"
|
||||
);
|
||||
}
|
||||
|
||||
/// The about page is a separate document from the app shell, so it inlines
|
||||
/// `style.css` itself. If that inlining were dropped, every mobile rule
|
||||
/// would be dead on this page specifically.
|
||||
#[tokio::test]
|
||||
async fn about_page_inlines_the_stylesheet() {
|
||||
let f = Fixture::fresh("about-css").login_over_http().await;
|
||||
let (status, body) = f.get_authed("/about").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body.contains(".about-page main > h1"),
|
||||
"about page should inline the stylesheet that styles it"
|
||||
);
|
||||
// Match the full selector, not just the class name. A substring check on
|
||||
// `.back-link` also matches `.back-link-foo`, which is how this test
|
||||
// passed the first time it was run against a deliberately broken sheet.
|
||||
assert!(
|
||||
body.contains(".about-page .back-link {"),
|
||||
"the inlined sheet should carry the back-link rules"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every screen must declare a viewport, or mobile browsers fall back to a
|
||||
/// ~980px layout viewport and scale the whole page down.
|
||||
///
|
||||
/// That silently disables every `@media (max-width: 600px)` rule in the
|
||||
/// stylesheet, so a screen can carry a full set of mobile rules and still
|
||||
/// render the desktop layout — which is exactly what happened to `/about`.
|
||||
/// The failure is invisible in a desktop browser and invisible to any test
|
||||
/// that only asserts the CSS was inlined, which is why it is pinned here
|
||||
/// per-screen rather than once.
|
||||
#[tokio::test]
|
||||
async fn every_screen_declares_a_viewport() {
|
||||
let f = Fixture::fresh("viewport").login_over_http().await;
|
||||
for path in ["/", "/about"] {
|
||||
let (status, body) = f.get_authed(path).await;
|
||||
assert_eq!(status, StatusCode::OK, "{path}");
|
||||
assert!(
|
||||
body.contains(r#"name="viewport""#)
|
||||
&& body.contains(r#"content="width=device-width, initial-scale=1"#),
|
||||
"{path} is missing a viewport meta tag; mobile will scale it down \
|
||||
and none of the max-width media queries will match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The about page advertises a version, and it must be the one in
|
||||
/// `Cargo.toml`.
|
||||
///
|
||||
/// The title bar used to carry a hand-written `"v0.1"` that had already
|
||||
/// drifted from the crate's `0.1.0`. Both now read `CARGO_PKG_VERSION`, so
|
||||
/// this pins that they agree with the manifest rather than with each other.
|
||||
#[tokio::test]
|
||||
async fn about_page_reports_the_manifest_version() {
|
||||
let f = Fixture::fresh("about-version").login_over_http().await;
|
||||
let (status, body) = f.get_authed("/about").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert!(
|
||||
body.contains(&format!("Version {version}")),
|
||||
"about page should report version {version}"
|
||||
);
|
||||
// The title bar must show the same string, not a separately maintained one.
|
||||
let (shell_status, shell) = f.get_authed("/").await;
|
||||
assert_eq!(shell_status, StatusCode::OK);
|
||||
assert!(
|
||||
shell.contains(&format!("EternaLibre Notes v{version}")),
|
||||
"title bar should show v{version} from the manifest"
|
||||
);
|
||||
}
|
||||
|
||||
/// The theme counts are derived from the palette, not typed in. If someone
|
||||
/// adds or removes a theme, this keeps the page honest without a manual edit.
|
||||
#[tokio::test]
|
||||
async fn about_page_theme_counts_come_from_the_palette() {
|
||||
use eternalibre_notes::themes::PREDEFINED_THEMES;
|
||||
|
||||
let total = PREDEFINED_THEMES.len();
|
||||
let dark = PREDEFINED_THEMES.iter().filter(|t| t.is_dark()).count();
|
||||
let light = total - dark;
|
||||
|
||||
let f = Fixture::fresh("about-themes").login_over_http().await;
|
||||
let (status, body) = f.get_authed("/about").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body.contains(&format!(
|
||||
"{total} built-in themes ({dark} dark, {light} light)"
|
||||
)),
|
||||
"about page should report {total} themes split {dark} dark / {light} light"
|
||||
);
|
||||
}
|
||||
|
||||
/// Outbound links must carry `rel="noopener noreferrer"`. The app advertises
|
||||
/// that it never talks to a third party, and these are the only links that
|
||||
/// leave the machine, so they should not leak a referrer when followed.
|
||||
#[tokio::test]
|
||||
async fn about_page_external_links_are_hardened() {
|
||||
let f = Fixture::fresh("about-links").login_over_http().await;
|
||||
let (status, body) = f.get_authed("/about").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
for target in [
|
||||
"https://github.com/MARKMENTAL",
|
||||
"https://mentalnet.xyz/forgejo-v2/",
|
||||
"https://github.com/MARKMENTAL/tuxdock",
|
||||
"https://github.com/MARKMENTAL/mentalnet-gnu-linux",
|
||||
] {
|
||||
assert!(body.contains(target), "about page should link to {target}");
|
||||
}
|
||||
|
||||
// Check every external anchor, so a link added later without the
|
||||
// hardening attributes fails here instead of shipping quietly.
|
||||
let externals: Vec<&str> = body
|
||||
.match_indices("<a href=\"https://")
|
||||
.map(|(i, _)| &body[i..])
|
||||
.collect();
|
||||
assert!(
|
||||
!externals.is_empty(),
|
||||
"expected external links on the about page"
|
||||
);
|
||||
for a in externals {
|
||||
let tag = &a[..a.find('>').expect("unterminated anchor")];
|
||||
assert!(
|
||||
tag.contains(r#"rel="noopener noreferrer""#),
|
||||
"external link missing rel hardening: {tag}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- base path ----
|
||||
|
||||
/// Every generated URL must carry the base prefix, or a browser behind the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue