Skip to content

fix(news): absolutize meshmonitor.org links in the news feed - #4377

Merged
Yeraze merged 3 commits into
mainfrom
fix/news-relative-links
Jul 28, 2026
Merged

fix(news): absolutize meshmonitor.org links in the news feed#4377
Yeraze merged 3 commits into
mainfrom
fix/news-relative-links

Conversation

@Yeraze

@Yeraze Yeraze commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

Blog posts link with site-root-relative paths (/features/atak) because that is what VitePress wants. scripts/blog-to-news.mjs copies post bodies verbatim into news.json, and the News popup renders that markdown inside a MeshMonitor instance — where /features/atak resolves against the instance's own base URL and 404s instead of opening meshmonitor.org.

The 4.13.2 release post surfaced it, but this was never a one-post problem: 13 of 45 feed items carried 64 root-relative targets, including every inline image in the offline-emergency-kit post.

Fixed at the generator so blog markdown stays portable for the docs site, plus a render-time rewrite in the popup so items already published to the live feed work without waiting for a docs rebuild.

Changes

  • scripts/blog-to-news.mjs — new absolutizeLinks() rewrites root-relative markdown links, images, reference-style definitions, and raw href/src attributes to https://meshmonitor.org as posts flow into news.json. Protocol-relative (//cdn…), absolute, anchor, and mailto: targets are left alone. The helper is exported and the top-level run is guarded by the usual process.argv[1] === fileURLToPath(import.meta.url) check (same pattern as lint-ratchet.mjs) so it is unit-testable; VitePress still invokes it via spawnSync, unchanged.
  • src/components/NewsPopup/NewsPopup.tsx — the a and img ReactMarkdown renderers absolutize root-relative URLs. This covers feed items already published relative, and is the only part that helps instances reading the live feed before the docs redeploy.
  • scripts/blog-to-news.test.mjs — 7 tests over the absolutizer (links, images, fragments, raw HTML, reference definitions, the leave-alone cases, multi-link documents).
  • src/components/NewsPopup/NewsPopup.test.tsx — 4 tests rendering the popup against a mocked feed, asserting the resolved href/src.
  • CHANGELOG.md — entry under [Unreleased].

Blog post sources are deliberately not edited — relative links are correct on meshmonitor.org and keep VitePress client-side routing working. docs/public/news.json is gitignored and regenerated by the docs build, so the site rebuild picks this up on its own.

Issues Resolved

None filed — reported directly against the 4.13.2 news item.

Documentation Updates

  • CHANGELOG.md[Unreleased] / Fixed entry.
  • No feature docs describe the blog→news pipeline, so nothing was stale. The invariant ("blog links stay relative; the generator absolutizes") is documented in a comment at the top of absolutizeLinks(), which is where an author would look.

Testing

  • Full unit suite passes — 11,123 passed, 0 failed, success: true (679 pending are the PostgreSQL/MySQL suites skipping with no local containers; no schema is touched here)
  • npx tsc -p tsconfig.server.json --noEmit clean
  • npm run lint:ci clean (no in-repo FAIL lines)
  • Regenerated news.json and verified: 0 root-relative targets remain, 64 absolutized, and the diff contains no non-link changes
  • Reviewer check: run npm run docs:news, then confirm the 4.13.2 item's ATAK and receive-only links read https://meshmonitor.org/features/…, and that the GitHub changelog link is untouched
  • Reviewer check: open the News popup in a dev container served under a BASE_URL (e.g. /meshmonitor) and confirm links open meshmonitor.org rather than the local instance

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read and analyze changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Evaluate test coverage
  • Check security and performance
  • Post final review

Overall this is a clean, well-scoped fix. The two-layer approach (generator + renderer) is the right design choice. A few observations:

scripts/blog-to-news.mjs

Regex: inline link captures more than intended

The first replace matches ]( followed by / not followed by /:

.replace(/(\]\()\/(?!\/)/g, `$1${SITE_ORIGIN}/`)

This works for [text](/path) and ![](/path), but it also matches inside a bare ]( that happens to be followed by a root-relative URL inside an already-absolute URL — e.g. [foo](https://meshmonitor.org/bar) would NOT be caught (the ]( is immediately followed by h, not /), so that specific case is fine. However, a URL like [foo](https://other.example.com/something?redirect=/path) contains =/ but no ](/, so this is also safe. The regex is sound for Markdown.

One edge case not covered: HTML block-level action= attributes

<form action="/submit"> uses action= not href=/src=. If any blog post uses a form this won't be absolutized — low risk for a tech blog, but worth noting.

Minor: toIsoString loses sub-second precision intentionally — good comment here would help

The replace(/\.\d{3}Z$/, 'Z') strips milliseconds. This is a deliberate normalization choice (YAML/gray-matter dates don't have ms), but a short comment would clarify intent for the next author.


src/components/NewsPopup/NewsPopup.tsx

absolutizeNewsUrl is correct and minimal — good.

The guard url.startsWith('/') && !url.startsWith('//') correctly handles the three cases:

  • /path → absolutized ✓
  • //cdn.example.com → left alone ✓
  • https://... → left alone ✓

img renderer is missing alt passthrough — potential lint/a11y issue

img(props) {
  const { node: _node, src, ...rest } = props;
  return <img {...rest} src={absolutizeNewsUrl(src)} />;
},

alt is included in ...rest so it IS passed through — this is fine. Disregard.

_node destructuring pattern is consistent with the repo convention — good.

No rel on images — correct, rel is for anchors only.


scripts/blog-to-news.test.mjs

Coverage is solid. All major cases are tested:

  • Inline links ✓
  • Images ✓
  • Fragment/anchor preservation ✓
  • Raw HTML href/src
  • Reference definitions ✓
  • Leave-alone cases (absolute, protocol-relative, anchor, mailto, relative) ✓
  • Multiple occurrences ✓

One gap: action= in raw HTML — consistent with the noted gap in the implementation, and low risk.

One gap: title attribute in links[text](/path) — the regex (\]\()\/(?!\/)/ matches ](/ so [text](/path) works fine (the title is inside the parens after the path). No issue here, but a test would make this explicit.


src/components/NewsPopup/NewsPopup.test.tsx

Good use of forceShowAll to bypass the lastSeenNewsId filter — tests the component in isolation cleanly.

api.getUserNewsStatus is not mocked — since isAuthenticated={false} is passed, getUserNewsStatus is never called, so the missing mock is harmless. If a test ever sets isAuthenticated={true} without adding that mock, it will produce an unhandled promise rejection. Consider adding a default mock in beforeEach as a guard:

(api.getUserNewsStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  lastSeenNewsId: null,
  dismissedNewsIds: [],
});

The waitFor in the protocol-relative test is inconsistent — the first three tests use findBy* (which internally uses waitFor), but the last uses explicit waitFor(() => expect(...)). Both work, but using findByRole would be stylistically consistent with the rest of the file.


CHANGELOG.md

Entry is clear, accurate, and appropriately detailed. No issues.


Summary

The fix is correct, the two-layer defense (generator + renderer) is well-justified, the tests cover the important cases, and the code follows repo conventions. The items above are minor polish — none are blockers. Ready to merge as-is, with optional cleanup.
| Branch: fix/news-relative-links

Comment thread scripts/blog-to-news.test.mjs Fixed
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read and analyze changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Evaluate test coverage
  • Check security and performance
  • Post final review

Overall this is a clean, well-scoped fix. The two-layer approach (generator + renderer) is the right design choice. A few observations:

scripts/blog-to-news.mjs

Regex: inline link captures more than intended

The first replace matches ]( followed by / not followed by /:

.replace(/(\]\()\/(?!\/)/g, `$1${SITE_ORIGIN}/`)

This works for [text](/path) and ![](/path), but it also matches inside a bare ]( that happens to be followed by a root-relative URL inside an already-absolute URL — e.g. [foo](https://meshmonitor.org/bar) would NOT be caught (the ]( is immediately followed by h, not /), so that specific case is fine. However, a URL like [foo](https://other.example.com/something?redirect=/path) contains =/ but no ](/, so this is also safe. The regex is sound for Markdown.

One edge case not covered: HTML block-level action= attributes

<form action="/submit"> uses action= not href=/src=. If any blog post uses a form this won't be absolutized — low risk for a tech blog, but worth noting.

Minor: toIsoString loses sub-second precision intentionally — good comment here would help

if (value instanceof Date) return value.toISOString().replace(/\.\d{3}Z$/, 'Z');

The replace(/\.\d{3}Z$/, 'Z') strips milliseconds. This is a deliberate normalization choice (YAML/gray-matter dates don't have ms), but a short comment would clarify intent for the next author.


src/components/NewsPopup/NewsPopup.tsx

absolutizeNewsUrl is correct and minimal — good.

The guard url.startsWith('/') && !url.startsWith('//') correctly handles the three cases:

  • /path → absolutized ✓
  • //cdn.example.com → left alone ✓
  • https://... → left alone ✓

img renderer — alt passthrough is fine

img(props) {
  const { node: _node, src, ...rest } = props;
  return <img {...rest} src={absolutizeNewsUrl(src)} />;
},

alt is included in ...rest, so accessibility is preserved. ✓

_node destructuring pattern is consistent with the repo convention — good.


scripts/blog-to-news.test.mjs — CodeQL flag

The CodeQL alert ("missing regular expression anchor") fires on lines like:

'See the [ATAK guide](https://meshmonitor.org/features/atak) for setup.'

CodeQL's string-as-regex heuristic is a false positive here. These are plain string literals passed to toBe(), not regular expressions. toBe uses Object.is equality, not regex matching — there is no anchoring concern. The alert can be suppressed with a // eslint-disable or CodeQL query suppression comment if it becomes noisy, but no code change is needed.


scripts/blog-to-news.test.mjs

Coverage is solid. All major cases are tested:

  • Inline links ✓
  • Images ✓
  • Fragment/anchor preservation ✓
  • Raw HTML href/src
  • Reference definitions ✓
  • Leave-alone cases (absolute, protocol-relative, anchor, mailto, relative) ✓
  • Multiple occurrences ✓

One gap: title attribute in links[text](/path) — the regex (\]\()\/(?!\/)/ matches ](/ so this works fine (the title is inside the parens after the path). No issue here, but a test would make this explicit.


src/components/NewsPopup/NewsPopup.test.tsx

Good use of forceShowAll to bypass the lastSeenNewsId filter — tests the component in isolation cleanly.

api.getUserNewsStatus is not mocked — since isAuthenticated={false} is passed, getUserNewsStatus is never called, so the missing mock is harmless. If a test ever sets isAuthenticated={true} without adding that mock, it will produce an unhandled promise rejection. Consider adding a default mock in beforeEach as a guard:

(api.getUserNewsStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  lastSeenNewsId: null,
  dismissedNewsIds: [],
});

The waitFor in the protocol-relative test is inconsistent — the first three tests use findBy* (which internally uses waitFor), but the last uses explicit waitFor(() => expect(...)). Both work, but using findByRole would be stylistically consistent with the rest of the file:

// instead of:
await waitFor(() => {
  expect(screen.getByRole('link', { name: 'cdn' })).toHaveAttribute('href', '//cdn.example.com/a.js');
});
// prefer:
const link = await screen.findByRole('link', { name: 'cdn' });
expect(link).toHaveAttribute('href', '//cdn.example.com/a.js');

CHANGELOG.md

Entry is clear, accurate, and appropriately detailed. No issues.


Summary

The fix is correct, the two-layer defense (generator + renderer) is well-justified, the tests cover the important cases, and the code follows repo conventions. The CodeQL alert is a false positive. The items above are minor polish — none are blockers. Ready to merge as-is, with optional cleanup.

Branch: fix/news-relative-links

Yeraze and others added 3 commits July 28, 2026 08:07
Blog posts link with site-root-relative paths ("/features/atak") because
that is what VitePress wants. The news feed copies post bodies verbatim,
and the News popup renders them inside a MeshMonitor instance, where a
root-relative path resolves against that instance's own base URL and
404s. The 4.13.2 post surfaced it, but 13 of 45 feed items were affected,
including every inline image.

Fix at the generator, so blog markdown stays portable: blog-to-news.mjs
now rewrites root-relative markdown links, images, reference definitions,
and raw href/src attributes to https://meshmonitor.org. Protocol-relative,
absolute, anchor, and mailto targets are left alone.

The News popup also rewrites root-relative href/src on render, so items
already published to the live feed work without waiting for a docs
rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
CodeQL flagged `out.match(/https:\/\/meshmonitor\.org/g)` as an
unanchored URL-host regex (js/incomplete-url-substring-sanitization).
It was never a sanitization check — it counted rewrites in generator
output — but an exact-output assertion is a stronger test and drops
the pattern entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
@Yeraze
Yeraze force-pushed the fix/news-relative-links branch from 5288b9a to 7da0d1c Compare July 28, 2026 12:07
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read and analyze changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Evaluate test coverage
  • Check security and performance
  • Post final review

Overall this is a clean, well-scoped fix. The two-layer approach (generator rewrite + render-time fallback) is the right design. Previous automated reviews have already covered most angles; this review adds a few additional observations.

scripts/blog-to-news.mjs

The three regexes are correct and minimal. The negative lookahead (?!\/) properly distinguishes root-relative (/path) from protocol-relative (//host) in all three patterns. The \b word boundary on the href|src pattern prevents matching things like nohref=. All good.

toIsoString millisecond stripping (line 31) — minor readability nit

if (value instanceof Date) return value.toISOString().replace(/\.\d{3}Z$/, 'Z');

A short inline comment like // strip ms — YAML dates have no sub-second precision would clarify intent for the next author. Not a blocker.

No action= attribute handling<form action="/path"> is not covered. Extremely low risk for a tech blog, but consistent with the noted gap in the test file.

Export guard pattern (line 77) is correct — same pattern as lint-ratchet.mjs, which makes the function testable without side effects. Well done.


src/components/NewsPopup/NewsPopup.tsx

absolutizeNewsUrl (lines 25–33) is correct. The startsWith('/') && !startsWith('//') guard cleanly handles all four cases: root-relative (rewrite), protocol-relative (leave), absolute (leave), anchor/mailto (leave).

img renderer passes alt through ...rest (line 302) — accessibility is preserved. ✓

a renderer always sets target="_blank" rel="noopener noreferrer" (lines 296–297) — this is the existing behavior for all news links (not introduced here), so no regression. Correct for external news content.

_node destructuring follows repo convention — consistent with the rest of the file.

No issues found in the component changes.


scripts/blog-to-news.test.mjs

CodeQL alert ("missing regular expression anchor") is a false positive. The flagged strings (e.g. 'https://meshmonitor.org/features/atak') are plain string literals passed to toBe(), which uses Object.is equality — not regex matching. No anchoring concern exists. The alert can be suppressed per-line with a CodeQL // lgtm or // codeql[...] -- false positive comment if it becomes noisy, but no logic change is needed.

Coverage is solid — all primary cases are exercised. One minor gap: no test for a link with a query string that contains a slash (e.g. [x](/path?a=b)). The regex handles it correctly (]( followed by / not /), but an explicit test would document the intent.

leave-alone cases test (line 43) verifies the negative cases in a single toBe(input) assertion, which is efficient and readable.


src/components/NewsPopup/NewsPopup.test.tsx

forceShowAll bypass is correct — avoids the lastSeenNewsId filter without mocking auth state, keeping tests focused.

getUserNewsStatus is not mocked — because isAuthenticated={false} is passed, it is never called, so no unhandled rejection. However, if a future test sets isAuthenticated={true}, the missing mock will produce an unhandled promise rejection. A defensive beforeEach mock would be a low-cost guard:

(api.getUserNewsStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  lastSeenNewsId: null,
  dismissedNewsIds: [],
});

Not required for this PR, but worth noting.

Test 4 (protocol-relative) uses waitFor + getByRole while tests 1–3 use findBy*. Both are correct, but findByRole would be stylistically consistent:

// current:
await waitFor(() => {
  expect(screen.getByRole('link', { name: 'cdn' })).toHaveAttribute('href', '//cdn.example.com/a.js');
});
// consistent with rest:
const link = await screen.findByRole('link', { name: 'cdn' });
expect(link).toHaveAttribute('href', '//cdn.example.com/a.js');

Minor style nit — no functional difference.


CHANGELOG.md

Entry is clear and accurate. ✓


Summary

The fix is correct and the two-layer defense is well-justified — the generator handles new posts, and the render-time rewrite covers the 64 already-published relative targets without requiring a docs rebuild. Regexes are sound, the export/guard pattern is testable, and the component changes are minimal. All items above are optional polish. Ready to merge.

Branch: fix/news-relative-links

@Yeraze
Yeraze merged commit d208b04 into main Jul 28, 2026
13 checks passed
@Yeraze
Yeraze deleted the fix/news-relative-links branch July 28, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants