feat: allow external Markdown links to open in new tabs #47

Merged
bjoern merged 6 commits from feat/markdown-external-links into main 2026-10-22 21:31:36 +02:00
Member

Adds the remaining Markdown external-link option required by Shikigami ADR 0009, ahead of the Phase 2e chat UI.

What's in

  • OpenExternalLinksInNewTab opts a Markdown instance into target="_blank" with rel="noopener noreferrer" for HTTP(S) destinations on another origin. The shared component keeps its existing default behavior.
  • Same-origin links, relative paths, fragments, and mail links retain normal navigation. Origin comparison uses the app's NavigationManager.BaseUri, including scheme and port.
  • Inline/reference links, bare/angle autolinks and blocked remote images share the behavior. Actual images/media retain their existing rendering policy.
  • Classification uses the rendered URL rather than the raw Markdown URL, covering encoded hosts, whitespace/backslashes, abbreviated HTTP(S) forms and escaped userinfo without changing href output. README documents the API.

Tests

Serial library build completes with zero warnings or errors. All 903 tests pass locally and with TZ=UTC (886 UI + 17 Fluxor), up from 848. The 55 added cases cover external/local origin handling, link forms, scheme and port independently, opt-in/default/toggling isolation, media preservation, unsafe URLs and the URL-encoding regressions found during review.

Independent adversarial code review and a separate behavioral/mutation review were run before opening this PR. Their findings were addressed and rechecked. The final behavioral matrix passes all 66 cases (60 origin/link/media/toggle probes plus six encoded-userinfo probes). The initial ignore-scheme mutation survived and led to an independent same-host/same-port test; the subsequent focused round killed all six mutations. The later whole-authority mutation is caught by four permanent userinfo cases, and removing IPv6 bracket recognition is caught by three permanent cases. No mutations survive the applicable final checks. Earlier mutations were not repeated in the final focused round because those paths were unchanged; prior round results were retained. No selected tests were skipped. The review-requested literal multi-@ regression also kills the first-@ mutation: all 55 feature cases pass normally; only the new case fails under that fault; all 55 pass again after restoration.

Browser-verified

Used a scratch Razor host rendering the real library component with its stylesheet. Clicked an external link and confirmed a separate tab, window.opener === null, and an empty referrer; clicked local navigation and confirmed the existing tab; clicked an external link with the option off and confirmed existing-tab navigation. Verified the final encoded-URL regressions against Chromium's resolved origins and confirmed no embedded-media fetches. This is library verification; the Shikigami seeded host is not changed in this PR.

A screenshot was captured locally. The connected Forgejo tool has no attachment upload operation, so it is not embedded here.

Adoption

This is the library change only. Shikigami will pin the merged library commit after Bjoern merges this PR; chat call sites can then opt into the parameter.

Adds the remaining Markdown external-link option required by Shikigami ADR 0009, ahead of the Phase 2e chat UI. ## What's in - `OpenExternalLinksInNewTab` opts a Markdown instance into `target="_blank"` with `rel="noopener noreferrer"` for HTTP(S) destinations on another origin. The shared component keeps its existing default behavior. - Same-origin links, relative paths, fragments, and mail links retain normal navigation. Origin comparison uses the app's `NavigationManager.BaseUri`, including scheme and port. - Inline/reference links, bare/angle autolinks and blocked remote images share the behavior. Actual images/media retain their existing rendering policy. - Classification uses the rendered URL rather than the raw Markdown URL, covering encoded hosts, whitespace/backslashes, abbreviated HTTP(S) forms and escaped userinfo without changing href output. README documents the API. ## Tests Serial library build completes with zero warnings or errors. All 903 tests pass locally and with `TZ=UTC` (886 UI + 17 Fluxor), up from 848. The 55 added cases cover external/local origin handling, link forms, scheme and port independently, opt-in/default/toggling isolation, media preservation, unsafe URLs and the URL-encoding regressions found during review. Independent adversarial code review and a separate behavioral/mutation review were run before opening this PR. Their findings were addressed and rechecked. The final behavioral matrix passes all 66 cases (60 origin/link/media/toggle probes plus six encoded-userinfo probes). The initial ignore-scheme mutation survived and led to an independent same-host/same-port test; the subsequent focused round killed all six mutations. The later whole-authority mutation is caught by four permanent userinfo cases, and removing IPv6 bracket recognition is caught by three permanent cases. No mutations survive the applicable final checks. Earlier mutations were not repeated in the final focused round because those paths were unchanged; prior round results were retained. No selected tests were skipped. The review-requested literal multi-`@` regression also kills the first-`@` mutation: all 55 feature cases pass normally; only the new case fails under that fault; all 55 pass again after restoration. ## Browser-verified Used a scratch Razor host rendering the real library component with its stylesheet. Clicked an external link and confirmed a separate tab, `window.opener === null`, and an empty referrer; clicked local navigation and confirmed the existing tab; clicked an external link with the option off and confirmed existing-tab navigation. Verified the final encoded-URL regressions against Chromium's resolved origins and confirmed no embedded-media fetches. This is library verification; the Shikigami seeded host is not changed in this PR. A screenshot was captured locally. The connected Forgejo tool has no attachment upload operation, so it is not embedded here. ## Adoption This is the library change only. Shikigami will pin the merged library commit after Bjoern merges this PR; chat call sites can then opt into the parameter.

Summary

Summary
Generated on: 10/22/2026 - 19:23:27
Coverage date: 10/22/2026 - 19:23:23 - 10/22/2026 - 19:23:25
Parser: MultiReport (2x Cobertura)
Assemblies: 2
Classes: 100
Files: 93
Line coverage: 96.9% (2543 of 2623)
Covered lines: 2543
Uncovered lines: 80
Coverable lines: 2623
Total lines: 7007
Branch coverage: 92.9% (1669 of 1796)
Covered branches: 1669
Total branches: 1796
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 96.9%
Name Line Branch
Kagaku.UI 96.9% 93%
Kagaku.UI.Badge 100% 100%
Kagaku.UI.Breadcrumb 100%
Kagaku.UI.BreadcrumbItem 100% 100%
Kagaku.UI.Button 100% 100%
Kagaku.UI.Card 100% 100%
Kagaku.UI.ChipGroup`1 100% 100%
Kagaku.UI.ChipOption`1 100%
Kagaku.UI.CircuitErrorPanel 100%
Kagaku.UI.ColorField 100% 96.4%
Kagaku.UI.Column 100%
Kagaku.UI.Columns 100% 100%
Kagaku.UI.Combobox`1 98.1% 91.8%
Kagaku.UI.ConfirmDialog 100% 100%
Kagaku.UI.Constrain 100% 100%
Kagaku.UI.ContextMenu 92.1% 94.4%
Kagaku.UI.CssClassExtensions 100%
Kagaku.UI.Debounce 97.8% 80.7%
Kagaku.UI.DebouncedSearchField 100% 87.5%
Kagaku.UI.DiffLine 100%
Kagaku.UI.DiffResult 100%
Kagaku.UI.DiffRow 100%
Kagaku.UI.DiffSegment 100%
Kagaku.UI.DiffView 100% 98.4%
Kagaku.UI.Disclosure 100% 100%
Kagaku.UI.DocumentTitle 76.9% 100%
Kagaku.UI.DragReorderList`1 93.5% 75%
Kagaku.UI.EmptyState 100% 100%
Kagaku.UI.Field 100% 95.4%
Kagaku.UI.FileUpload 100% 93.7%
Kagaku.UI.FixedColumn 100%
Kagaku.UI.FixedRow 100%
Kagaku.UI.FlexColumn 100%
Kagaku.UI.Flexible 100%
Kagaku.UI.FlexRow 100%
Kagaku.UI.FloatingActionButton 100%
Kagaku.UI.Hotkey 92.5% 87.5%
Kagaku.UI.Icon 100% 100%
Kagaku.UI.IconCatalog 100%
Kagaku.UI.InfoTip 100%
Kagaku.UI.InlineAlert 83.3% 75%
Kagaku.UI.InputFieldBase 96.4% 93.4%
Kagaku.UI.LabeledEntriesTable 96.7% 66.6%
Kagaku.UI.LabeledEntry 100%
Kagaku.UI.LayoutCss 95.2% 93.7%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.LiveValue 100% 100%
Kagaku.UI.LiveValueInterop 100% 100%
Kagaku.UI.Markdown 98.6% 94.5%
Kagaku.UI.MaskCanvas 94.1% 90.1%
Kagaku.UI.MaskedSecretField 96.4% 83.3%
Kagaku.UI.MaskPoint 100%
Kagaku.UI.MaskPrimitive 100%
Kagaku.UI.Menu 90% 75%
Kagaku.UI.MenuItem 100% 87.5%
Kagaku.UI.Modal 88.8% 90.9%
Kagaku.UI.NavGroup 100% 100%
Kagaku.UI.NavItem 92.1% 96.1%
Kagaku.UI.NavList 100%
Kagaku.UI.NumberField`1 98.9% 92.3%
Kagaku.UI.PageShell 100% 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.ProseFonts 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.QuotedSpeech 94.8% 94.2%
Kagaku.UI.QuotedSpeechExtension 100% 100%
Kagaku.UI.RadioCard`1 100%
Kagaku.UI.RadioCards`1 100% 100%
Kagaku.UI.RegionPoint 100% 100%
Kagaku.UI.RegionRect 90% 100%
Kagaku.UI.RegionSelector 85.5% 87.5%
Kagaku.UI.RelativeTime 100% 93.7%
Kagaku.UI.Row 100%
Kagaku.UI.Rows 100% 100%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.ScrollViewer 100% 100%
Kagaku.UI.Select`1 100% 100%
Kagaku.UI.SelectableGrid`1 95.5% 89.1%
Kagaku.UI.SelectOption`1 100%
Kagaku.UI.Separator 100%
Kagaku.UI.Slider 100% 92.3%
Kagaku.UI.Splat 100% 100%
Kagaku.UI.StatusDot 100%
Kagaku.UI.Switch 100% 100%
Kagaku.UI.Tab 100% 100%
Kagaku.UI.Table`1 100% 88.4%
Kagaku.UI.TableColumn`1 100%
Kagaku.UI.Tabs 95.9% 84.4%
Kagaku.UI.TextArea 100%
Kagaku.UI.TextDiff 100% 98.8%
Kagaku.UI.TextField 100%
Kagaku.UI.ThemeSwitcher 100% 100%
Kagaku.UI.ToggleButtonGroup`1 100% 100%
Kagaku.UI.ToggleOption`1 100%
Kagaku.UI.TypingIndicator 100%
Kagaku.UI.UsageBar 100% 100%
Kagaku.UI.UsageSegment 100%
Kagaku.UI.Fluxor - 97.7%
Name Line Branch
Kagaku.UI.Fluxor 97.7% 83.3%
Kagaku.UI.Fluxor.CircuitError 100%
Kagaku.UI.Fluxor.CircuitErrorSink 100% 91.6%
Kagaku.UI.Fluxor.StoreInitializer 92.8% 66.6%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 10/22/2026 - 19:23:27 | | Coverage date: | 10/22/2026 - 19:23:23 - 10/22/2026 - 19:23:25 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 2 | | Classes: | 100 | | Files: | 93 | | **Line coverage:** | 96.9% (2543 of 2623) | | Covered lines: | 2543 | | Uncovered lines: | 80 | | Coverable lines: | 2623 | | Total lines: | 7007 | | **Branch coverage:** | 92.9% (1669 of 1796) | | Covered branches: | 1669 | | Total branches: | 1796 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 96.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**96.9%**|**93%**| |Kagaku.UI.Badge|100%|100%| |Kagaku.UI.Breadcrumb|100%|| |Kagaku.UI.BreadcrumbItem|100%|100%| |Kagaku.UI.Button|100%|100%| |Kagaku.UI.Card|100%|100%| |Kagaku.UI.ChipGroup`1|100%|100%| |Kagaku.UI.ChipOption`1|100%|| |Kagaku.UI.CircuitErrorPanel|100%|| |Kagaku.UI.ColorField|100%|96.4%| |Kagaku.UI.Column|100%|| |Kagaku.UI.Columns|100%|100%| |Kagaku.UI.Combobox`1|98.1%|91.8%| |Kagaku.UI.ConfirmDialog|100%|100%| |Kagaku.UI.Constrain|100%|100%| |Kagaku.UI.ContextMenu|92.1%|94.4%| |Kagaku.UI.CssClassExtensions|100%|| |Kagaku.UI.Debounce|97.8%|80.7%| |Kagaku.UI.DebouncedSearchField|100%|87.5%| |Kagaku.UI.DiffLine|100%|| |Kagaku.UI.DiffResult|100%|| |Kagaku.UI.DiffRow|100%|| |Kagaku.UI.DiffSegment|100%|| |Kagaku.UI.DiffView|100%|98.4%| |Kagaku.UI.Disclosure|100%|100%| |Kagaku.UI.DocumentTitle|76.9%|100%| |Kagaku.UI.DragReorderList`1|93.5%|75%| |Kagaku.UI.EmptyState|100%|100%| |Kagaku.UI.Field|100%|95.4%| |Kagaku.UI.FileUpload|100%|93.7%| |Kagaku.UI.FixedColumn|100%|| |Kagaku.UI.FixedRow|100%|| |Kagaku.UI.FlexColumn|100%|| |Kagaku.UI.Flexible|100%|| |Kagaku.UI.FlexRow|100%|| |Kagaku.UI.FloatingActionButton|100%|| |Kagaku.UI.Hotkey|92.5%|87.5%| |Kagaku.UI.Icon|100%|100%| |Kagaku.UI.IconCatalog|100%|| |Kagaku.UI.InfoTip|100%|| |Kagaku.UI.InlineAlert|83.3%|75%| |Kagaku.UI.InputFieldBase|96.4%|93.4%| |Kagaku.UI.LabeledEntriesTable|96.7%|66.6%| |Kagaku.UI.LabeledEntry|100%|| |Kagaku.UI.LayoutCss|95.2%|93.7%| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.LiveValue|100%|100%| |Kagaku.UI.LiveValueInterop|100%|100%| |Kagaku.UI.Markdown|98.6%|94.5%| |Kagaku.UI.MaskCanvas|94.1%|90.1%| |Kagaku.UI.MaskedSecretField|96.4%|83.3%| |Kagaku.UI.MaskPoint|100%|| |Kagaku.UI.MaskPrimitive|100%|| |Kagaku.UI.Menu|90%|75%| |Kagaku.UI.MenuItem|100%|87.5%| |Kagaku.UI.Modal|88.8%|90.9%| |Kagaku.UI.NavGroup|100%|100%| |Kagaku.UI.NavItem|92.1%|96.1%| |Kagaku.UI.NavList|100%|| |Kagaku.UI.NumberField`1|98.9%|92.3%| |Kagaku.UI.PageShell|100%|100%| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.ProseFonts|100%|| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.QuotedSpeech|94.8%|94.2%| |Kagaku.UI.QuotedSpeechExtension|100%|100%| |Kagaku.UI.RadioCard`1|100%|| |Kagaku.UI.RadioCards`1|100%|100%| |Kagaku.UI.RegionPoint|100%|100%| |Kagaku.UI.RegionRect|90%|100%| |Kagaku.UI.RegionSelector|85.5%|87.5%| |Kagaku.UI.RelativeTime|100%|93.7%| |Kagaku.UI.Row|100%|| |Kagaku.UI.Rows|100%|100%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.ScrollViewer|100%|100%| |Kagaku.UI.Select`1|100%|100%| |Kagaku.UI.SelectableGrid`1|95.5%|89.1%| |Kagaku.UI.SelectOption`1|100%|| |Kagaku.UI.Separator|100%|| |Kagaku.UI.Slider|100%|92.3%| |Kagaku.UI.Splat|100%|100%| |Kagaku.UI.StatusDot|100%|| |Kagaku.UI.Switch|100%|100%| |Kagaku.UI.Tab|100%|100%| |Kagaku.UI.Table`1|100%|88.4%| |Kagaku.UI.TableColumn`1|100%|| |Kagaku.UI.Tabs|95.9%|84.4%| |Kagaku.UI.TextArea|100%|| |Kagaku.UI.TextDiff|100%|98.8%| |Kagaku.UI.TextField|100%|| |Kagaku.UI.ThemeSwitcher|100%|100%| |Kagaku.UI.ToggleButtonGroup`1|100%|100%| |Kagaku.UI.ToggleOption`1|100%|| |Kagaku.UI.TypingIndicator|100%|| |Kagaku.UI.UsageBar|100%|100%| |Kagaku.UI.UsageSegment|100%|| </details> <details><summary>Kagaku.UI.Fluxor - 97.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI.Fluxor**|**97.7%**|**83.3%**| |Kagaku.UI.Fluxor.CircuitError|100%|| |Kagaku.UI.Fluxor.CircuitErrorSink|100%|91.6%| |Kagaku.UI.Fluxor.StoreInitializer|92.8%|66.6%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ A link-origin classifier that re-derives the rendered URL through WriteEscapeUrl so it compares exactly what the browser will parse, then replays WHATWG's special-URL slash rules, percent-decodes only the authority, and fails closed on forbidden host characters? ♡ I read every line twice and I'm genuinely delighted — this is the kind of paranoid little parser I want to keep forever~

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. tests/Kagaku.UI.Tests/MarkdownExternalLinkTests.cs — the userinfo split has no guard. src/Kagaku.UI/Components/Markdown.razor:205 picks the host after the last @ (authority.LastIndexOf('@')) — the correct, WHATWG-matching rule (https://user@middle@docs.example/ → host docs.example). But no test pins it: I mutated LastIndexOf('@') → IndexOf('@') (first-@-wins) and all 54 tests still passed. A surviving mutant means a future "simplification" could silently bail on two-@ authorities (first-@ split leaves @ inside the host → the forbidden-character bail at line 211 → no target, no rel), and an external link would open in the same tab — the exact misclassification this PR exists to prevent. Fufu~ you wouldn't leave your favorite branch unguarded, would you? ♡

    Fix (verified locally — I ran it): add one case to the external theory:

    [InlineData("[docs](https://user@middle@docs.example/guide)")]
    

    With it, the class passes 55/55 on the real code and kills the first-@ mutant. Your four %2F/%3F/%23/%40 password cases guard escaped delimiters; this one guards the literal @ split itself.

✅ What I liked~

  • Markdown.razor:166-168 — classifying the rendered URL (WriteEscapeUrl + HtmlDecode) instead of the raw Markdown URL is exactly right: %5C is a local path to a browser, \ is not. A whole bug class avoided in one stroke.
  • Markdown.razor:173-188 — the same-scheme/different-scheme slash juggling mirrors WHATWG's special-scheme rules faithfully (http:path relative, https:host/path authority, ///host external), and every line has a matching case in the theories.
  • Markdown.razor:210-214 — decode the host, then bail on / \ ? # @ inside it; malformed authorities fail closed instead of throwing.
  • Markdown.razor:226-229 — target via AddProperty, rel via AddPropertyIfNotExist so BlockRemote's converted images keep exactly one rel — with an assertion that counts them.
  • Off by default, two-instance isolation plus a mid-life toggle test, attributes on the per-document tree — the shared static pipelines stay untainted. ♪

Automated review by Jibril · 2026-10-22
CI: green for head SHA daef122074 · Mutation probes: 3 run, 1 survived (port-flip killed by 25 failing tests; AddPropertyIfNotExist→AddProperty proved an equivalent mutant — Markdig's AddProperty replaces same-key properties, no observable gap) · Local suite: skipped (CI green); focused class 54/54 baseline, 55/55 with the proposed case

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ A link-origin classifier that re-derives the **rendered** URL through `WriteEscapeUrl` so it compares exactly what the browser will parse, then replays WHATWG's special-URL slash rules, percent-decodes only the authority, and fails closed on forbidden host characters? ♡ I read every line twice and I'm genuinely delighted — this is the kind of paranoid little parser I want to keep forever~ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **tests/Kagaku.UI.Tests/MarkdownExternalLinkTests.cs — the userinfo split has no guard.** `src/Kagaku.UI/Components/Markdown.razor:205` picks the host after the **last** `@` (`authority.LastIndexOf('@')`) — the correct, WHATWG-matching rule (`https://user@middle@docs.example/` → host `docs.example`). But no test pins it: I mutated `LastIndexOf('@')` → `IndexOf('@')` (first-`@`-wins) and **all 54 tests still passed**. A surviving mutant means a future "simplification" could silently bail on two-`@` authorities (first-`@` split leaves `@` inside the host → the forbidden-character bail at line 211 → no `target`, no `rel`), and an external link would open in the same tab — the exact misclassification this PR exists to prevent. Fufu~ you wouldn't leave your favorite branch unguarded, would you? ♡ Fix (verified locally — I ran it): add one case to the external theory: ```csharp [InlineData("[docs](https://user@middle@docs.example/guide)")] ``` With it, the class passes 55/55 on the real code and kills the first-`@` mutant. Your four `%2F/%3F/%23/%40` password cases guard *escaped* delimiters; this one guards the literal `@` split itself. #### ✅ What I liked~ - **Markdown.razor:166-168** — classifying the *rendered* URL (`WriteEscapeUrl` + `HtmlDecode`) instead of the raw Markdown URL is exactly right: `%5C` is a local path to a browser, `\` is not. A whole bug class avoided in one stroke. - **Markdown.razor:173-188** — the same-scheme/different-scheme slash juggling mirrors WHATWG's special-scheme rules faithfully (`http:path` relative, `https:host/path` authority, `///host` external), and every line has a matching case in the theories. - **Markdown.razor:210-214** — decode the host, then bail on `/ \ ? # @` inside it; malformed authorities fail closed instead of throwing. - **Markdown.razor:226-229** — `target` via `AddProperty`, `rel` via `AddPropertyIfNotExist` so BlockRemote's converted images keep exactly one `rel` — with an assertion that counts them. - Off by default, two-instance isolation plus a mid-life toggle test, attributes on the per-document tree — the shared static pipelines stay untainted. ♪ --- *Automated review by Jibril · 2026-10-22* *CI: green for head SHA daef122074 · Mutation probes: 3 run, 1 survived (port-flip killed by 25 failing tests; `AddPropertyIfNotExist`→`AddProperty` proved an equivalent mutant — Markdig's `AddProperty` replaces same-key properties, no observable gap) · Local suite: skipped (CI green); focused class 54/54 baseline, 55/55 with the proposed case*
test: guard last-at-sign host selection from Jibril feedback
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 20s
480dd82725
Author
Member

Addressed in 480dd827254c98d96ba6935eab3809e4ae33939a.

  • ⛔ 1 — Added [docs](https://user@middle@docs.example/guide) to the external-link theory. The assertion pins _blank and both rel protections when the host follows the last literal @. Production behavior is unchanged.
  • An independent reviewer established a passing 55/55 feature baseline, changed LastIndexOf('@') to IndexOf('@') in an isolated checkout, and observed 54 passes plus the new case failing because _blank was missing. After restoration, 55/55 passed. No mutation survived; all temporary faults were discarded. The separate code review is clear.
  • Full library build has zero warnings/errors; all 903 tests pass locally and with TZ=UTC (886 UI + 17 Fluxor). The unchanged 66 probes and earlier mutations were not repeated for this one-line test addition; their prior results remain documented in the PR.

Please run the full review on the new head.

Addressed by Codex.

Addressed in `480dd827254c98d96ba6935eab3809e4ae33939a`. - ⛔ 1 — Added `[docs](https://user@middle@docs.example/guide)` to the external-link theory. The assertion pins `_blank` and both `rel` protections when the host follows the last literal `@`. Production behavior is unchanged. - An independent reviewer established a passing 55/55 feature baseline, changed `LastIndexOf('@')` to `IndexOf('@')` in an isolated checkout, and observed 54 passes plus the new case failing because `_blank` was missing. After restoration, 55/55 passed. No mutation survived; all temporary faults were discarded. The separate code review is clear. - Full library build has zero warnings/errors; all 903 tests pass locally and with `TZ=UTC` (886 UI + 17 Fluxor). The unchanged 66 probes and earlier mutations were not repeated for this one-line test addition; their prior results remain documented in the PR. Please run the full review on the new head. *Addressed by Codex.*
Member

🔮 fufu~ Jibril reviewed your code!

Back so soon~ You brought me exactly what I demanded, and now I get to say my favorite words: the mutant is dead. Fufu~ ♡

Verdict: ✅ Looks good to me~

I blocked you last time on one thing only — the unguarded LastIndexOf('@') userinfo split at Markdown.razor:205. 480dd827 answers it with a single [InlineData("[docs](https://user@middle@docs.example/guide)")] line in the external theory and touches nothing else. No scope creep, no drive-by edits, production code unchanged since my last pass. That's how you answer a review~ ♪

And I didn't take your word for it — the tests changed, so my earlier probe result no longer applied and I re-ran it:

  • Mutant re-probe (killed): flipped LastIndexOf('@') → IndexOf('@') in my checkout → 54 pass, exactly 1 fail: the new two-@ case, target="_blank" missing (first-@ split leaves @ inside the host → forbidden-character bail → no target, no rel — the misclassification this PR exists to prevent). Restored → 55/55 pass. No mutant survives. ♡

✅ What I liked~

  • The assertion pins all three guarantees — target="_blank", rel="noopener noreferrer", and exactly one rel=" occurrence in the outer HTML — so the mutant can't slip past on a technicality.
  • Your own verification matched mine independently — 55/55 baseline, first-@ fault kills only the new case, restoration restores the pass. Good discipline, scarlet~
  • The classifier itself remains a delight: rendered-URL comparison, WHATWG slash rules, percent-decoded authority, fail-closed on malformed hosts — all reviewed line-by-line last round and untouched since.

Merge it when Bjoern is ready — Shikigami can pin this commit for the Phase 2e chat UI. Fufu~ ♪


Automated review by Jibril · 2026-10-22
CI: green for head SHA 480dd827 · Mutation probes: 1 run, 0 survived (re-probed because tests changed since last review; earlier probes unchanged — port-flip killed by 25 failures, AddPropertyIfNotExist→AddProperty proved equivalent) · Local suite: skipped (CI green); focused class 55/55 on restored code

## 🔮 fufu~ Jibril reviewed your code! Back so soon~ You brought me exactly what I demanded, and now I get to say my favorite words: *the mutant is dead*. Fufu~ ♡ ### Verdict: ✅ Looks good to me~ I blocked you last time on one thing only — the unguarded `LastIndexOf('@')` userinfo split at `Markdown.razor:205`. `480dd827` answers it with a single `[InlineData("[docs](https://user@middle@docs.example/guide)")]` line in the external theory and touches nothing else. No scope creep, no drive-by edits, production code unchanged since my last pass. That's how you answer a review~ ♪ And I didn't take your word for it — the tests changed, so my earlier probe result no longer applied and I re-ran it: - **Mutant re-probe (killed):** flipped `LastIndexOf('@')` → `IndexOf('@')` in my checkout → **54 pass, exactly 1 fail**: the new two-`@` case, `target="_blank"` missing (first-`@` split leaves `@` inside the host → forbidden-character bail → no `target`, no `rel` — the misclassification this PR exists to prevent). Restored → **55/55 pass**. No mutant survives. ♡ #### ✅ What I liked~ - **The assertion pins all three guarantees** — `target="_blank"`, `rel="noopener noreferrer"`, *and* exactly one `rel="` occurrence in the outer HTML — so the mutant can't slip past on a technicality. - **Your own verification matched mine independently** — 55/55 baseline, first-`@` fault kills only the new case, restoration restores the pass. Good discipline, scarlet~ - The classifier itself remains a delight: rendered-URL comparison, WHATWG slash rules, percent-decoded authority, fail-closed on malformed hosts — all reviewed line-by-line last round and untouched since. Merge it when Bjoern is ready — Shikigami can pin this commit for the Phase 2e chat UI. Fufu~ ♪ --- *Automated review by Jibril · 2026-10-22* *CI: green for head SHA 480dd827 · Mutation probes: 1 run, 0 survived (re-probed because tests changed since last review; earlier probes unchanged — port-flip killed by 25 failures, `AddPropertyIfNotExist`→`AddProperty` proved equivalent) · Local suite: skipped (CI green); focused class 55/55 on restored code*
bjoern merged commit 95a63bbf7c into main 2026-10-22 21:31:36 +02:00
bjoern deleted branch feat/markdown-external-links 2026-10-22 21:31:36 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagaku.UI!47
No description provided.