From 75ee1624bdbdf110bed05ca2aa61fdaa48729e85 Mon Sep 17 00:00:00 2001 From: 0x5c0f Date: Sat, 11 Apr 2026 14:25:17 +0800 Subject: [PATCH] feat(link): add external link guard feature support (#729) * feat(external-link-guard): add native redirect mode and improve external-link guard UX * fix(external-link-guard): remove redirect_url and clarify scope/new_tab_policy docs * i18n(external-link-guard): localize guard texts across non-English locales * feat(config)!: migrate `site.Params.externalIcon` to `site.Params.link.external_icon` * refactor: implement regular and card link rendering * feat(link-guard): implement link redirection page with copy and confirm functionality * revert * feat: implement link guard dialog for external links --------- Co-authored-by: Cell <1024@lruihao.cn> --- apps/test/content/posts/link-test.md | 88 +++++++ apps/test/hugo.toml | 4 + assets/css/_core/_base.scss | 9 + assets/css/_core/_mixin/_index.scss | 1 + assets/css/_core/_mixin/_link-guard.scss | 83 +++++++ assets/css/_core/_widgets/_index.scss | 1 + assets/css/_core/_widgets/_link-guard.scss | 70 ++++++ assets/css/_page/_index.scss | 3 +- assets/css/_page/_link.scss | 19 ++ assets/css/_page/_single/_toc.scss | 16 +- assets/js/page/link.js | 44 ++++ assets/js/theme.js | 72 ++++++ hugo.toml | 61 ++++- i18n/de.toml | 7 + i18n/en.toml | 7 + i18n/es.toml | 7 + i18n/fr.toml | 7 + i18n/hi.toml | 7 + i18n/it.toml | 7 + i18n/ja.toml | 7 + i18n/ko.toml | 7 + i18n/pl.toml | 7 + i18n/pt-BR.toml | 7 + i18n/ro.toml | 7 + i18n/ru.toml | 7 + i18n/sr.toml | 7 + i18n/vi.toml | 7 + i18n/zh-CN.toml | 7 + i18n/zh-TW.toml | 7 + layouts/_markup/render-link.html | 2 +- layouts/_partials/_debug/dump.html | 10 + .../_partials/_debug/template-call-stack.html | 3 +- layouts/_partials/function/get-file-tree.html | 2 +- layouts/_partials/layout/footer.html | 2 +- layouts/_partials/layout/widgets.html | 22 +- layouts/_partials/plugin/link.html | 217 ++++++++++++------ layouts/_partials/plugin/social.html | 2 +- layouts/_partials/single/footer.html | 3 +- layouts/_partials/single/post-author.html | 2 +- layouts/_shortcodes/link.html | 2 +- layouts/home.link.html | 30 +++ layouts/posts/single.html | 3 +- layouts/robots.txt | 1 + 43 files changed, 786 insertions(+), 98 deletions(-) create mode 100644 apps/test/content/posts/link-test.md create mode 100644 assets/css/_core/_mixin/_link-guard.scss create mode 100644 assets/css/_core/_widgets/_link-guard.scss create mode 100644 assets/css/_page/_link.scss create mode 100644 assets/js/page/link.js create mode 100644 layouts/_partials/_debug/dump.html create mode 100644 layouts/home.link.html diff --git a/apps/test/content/posts/link-test.md b/apps/test/content/posts/link-test.md new file mode 100644 index 00000000..f76256bb --- /dev/null +++ b/apps/test/content/posts/link-test.md @@ -0,0 +1,88 @@ +--- +title: Link Rendering Test +date: 2026-04-10T11:40:06+08:00 +collections: + - Tests +categories: + - Markdown +tags: + - Link +--- + + +This post validates link rendering paths in FixIt, including Markdown render hook and link shortcode modes. + + + +## Markdown Render Hook (Regular Link) + +These links are rendered by `layouts/_markup/render-link.html` and should always produce regular links. + +- Internal relative link: [Task list test](/test/units/task-list-test/) +- Internal anchor link: [Jump to Card Link section](#card-link-shortcode) +- Absolute same-site link: [Test site home](http://demo.fixit.lruihao.cn/test/) +- External HTTP link: [FixIt Repository](https://github.com/hugo-fixit/FixIt) +- Mail link: [Email test](mailto:test@example.com) +- JavaScript link: [JavaScript test](javascript:void(0)) +- Tel link: [Phone test](tel:+1234567890) +- File link: [File test](file:///path/to/file.txt) +- Data link: [Data URI test](data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==) + +### Title Attribute (Markdown) + +[External link with title](https://example.com "Example Title") + +## Link Shortcode (Regular) + +### Named parameters + +{{< link href="https://github.com/hugo-fixit/FixIt" content="GitHub via shortcode" title="GitHub title" >}} + +{{< link href="/test/units/codeblock-test/" content="Internal shortcode link" class="shortcode-link-test" >}} + +{{< link href="https://example.com" content="Custom rel + no noreferrer" rel="ugc" noreferrer=false >}} + +{{< link href="/images/fixit.svg" content="Download local file" download="fixit.svg" >}} + +### Positional parameters + +{{< link "https://example.org" "Positional regular link" "Positional title" >}} + +## Card Link Shortcode + +### Basic card + +{{< link href="https://fixit.lruihao.cn" content="FixIt Documentation" card=true >}} + +### Card with custom icon class + +{{< link href="https://github.com/hugo-fixit/FixIt" content="FixIt on GitHub" card=true card-icon="fa-brands fa-github" >}} + +### Card with custom image icon + +{{< link href="https://gohugo.io" content="Hugo Official Site" card=true card-icon="/test/images/hugo.min.svg" >}} + +### Card with download state + +{{< link href="/images/fixit.svg" content="Download as card" card=true download="fixit.svg" >}} + +### Positional card parameters + +{{< link "https://example.com" "Positional card link" "" true "fa-solid fa-link" >}} + +## Edge Cases + +- URL with query string: + [Search query test](https://example.com/search?q=fixit&lang=en) +- URL with fragment: + [Fragment test](https://example.com/docs#installation) +- Long URL display (card meta should trim protocol automatically): + {{< link href="https://example.com/a/very/long/path/for/testing/card/url/display?foo=bar&baz=qux" content="Long URL card" card=true >}} + +## Manual Verification Checklist + +- Regular Markdown external links include external behavior and icon policy. +- Internal links are not treated as external. +- Shortcode card links render `.card-link` structure and card meta URL. +- `download` shows download icon/state in both regular and card modes. +- `noreferrer=false` removes `noopener noreferrer` from generated rel values. diff --git a/apps/test/hugo.toml b/apps/test/hugo.toml index 7c0bf6a6..5445dbcd 100644 --- a/apps/test/hugo.toml +++ b/apps/test/hugo.toml @@ -49,3 +49,7 @@ avatar = "https://lruihao.cn/images/avatar.jpg" [params.mermaid] zenuml = "https://cdn.jsdelivr.net/npm/@mermaid-js/mermaid-zenuml/dist/mermaid-zenuml.esm.min.mjs" + +[params.dev] +enable = true +debug = true diff --git a/assets/css/_core/_base.scss b/assets/css/_core/_base.scss index cc1e24d2..dacbbf25 100644 --- a/assets/css/_core/_base.scss +++ b/assets/css/_core/_base.scss @@ -12,6 +12,10 @@ html { container-type: scroll-state; container-name: scroller; + &:has(dialog[open]) { + overflow: hidden; + } + @include media('reduce-motion') { scroll-behavior: auto; } @@ -41,6 +45,11 @@ button { cursor: pointer; } +button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + summary { cursor: pointer; } diff --git a/assets/css/_core/_mixin/_index.scss b/assets/css/_core/_mixin/_index.scss index d7f77fc6..45696ca4 100644 --- a/assets/css/_core/_mixin/_index.scss +++ b/assets/css/_core/_mixin/_index.scss @@ -3,6 +3,7 @@ @import 'box-shadow'; @import 'compatibility'; @import 'link'; +@import 'link-guard'; @import 'loading'; @import 'media'; @import 'scrollbar-width'; diff --git a/assets/css/_core/_mixin/_link-guard.scss b/assets/css/_core/_mixin/_link-guard.scss new file mode 100644 index 00000000..88314262 --- /dev/null +++ b/assets/css/_core/_mixin/_link-guard.scss @@ -0,0 +1,83 @@ +/// Shared styles for link guard content blocks. +/// Keep this as the single source of truth for both link page card and guard dialog body. +@mixin link-guard-content { + padding: 1.5rem; + border: 1px solid fixit-var(global-border-color-light); + background: light-dark(#fff, #1d2230); + box-shadow: 0 1rem 2rem light-dark(rgba(15, 23, 42, 0.18), rgba(0, 0, 0, 0.35)); + @include border-radius(1rem); + + .title { + margin-block: 0 0.75rem; + font-size: 1.25rem; + } + + .message { + font-weight: 600; + margin-block: 0 0.75rem; + } + + .target-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.875rem; + } + + .target { + margin: 0; + padding: 0.5rem 1rem; + flex: 1 1 auto; + min-width: 0; + background: light-dark(#f8fafc, #171b25); + border: 1px solid fixit-var(global-border-color); + font-family: fixit-var(code-font-family); + font-size: 0.875rem; + line-height: 1rem; + word-break: break-all; + @include border-radius; + } + + .copy-btn { + box-sizing: content-box; + width: 1rem; + height: 1rem; + flex-shrink: 0; + padding: 0.5rem; + line-height: 1; + background: light-dark(#f8fafc, #171b25); + border: 1px solid fixit-var(global-border-color); + color: fixit-var(global-font-secondary-color); + @include border-radius; + @include user-select(none); + + &[data-copied] .fa-clone { + --fa: "\f00c"; + font-weight: 900; + color: fixit-var(global-link-hover-color); + } + } + + .actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + font-size: 0.875rem; + } + + .cancel-btn, + .confirm-btn { + padding: 0.5rem 0.875rem; + border: 1px solid fixit-var(global-border-color-light); + background: light-dark(#f6f8fa, #2a313c); + color: fixit-var(global-font-color); + @include border-radius; + @include user-select(none); + } + + .confirm-btn { + color: #fff; + background-color: fixit-var(primary); + border-color: fixit-var(primary); + } +} diff --git a/assets/css/_core/_widgets/_index.scss b/assets/css/_core/_widgets/_index.scss index 978d608a..bea665ad 100644 --- a/assets/css/_core/_widgets/_index.scss +++ b/assets/css/_core/_widgets/_index.scss @@ -4,6 +4,7 @@ @import 'fixed-button'; @import 'github-corner'; @import 'icon'; +@import 'link-guard'; @import 'mask'; @import 'noscript-warning'; @import 'post-chat'; diff --git a/assets/css/_core/_widgets/_link-guard.scss b/assets/css/_core/_widgets/_link-guard.scss new file mode 100644 index 00000000..e42addef --- /dev/null +++ b/assets/css/_core/_widgets/_link-guard.scss @@ -0,0 +1,70 @@ +#link-guard-dialog { + width: MIN(600px, calc(100% - 2rem)); + max-width: 100%; + border: none; + padding: 0; + color: fixit-var(global-font-color); + background-color: transparent; + opacity: 0; + transform: translateY(12px) scale(0.98); + transition: + display 0.2s allow-discrete, + overlay 0.2s allow-discrete, + transform 0.2s, + opacity 0.2s; + + .guard-content { + @include link-guard-content; + } + + &::backdrop { + background-color: rgba(0, 0, 0, 0.25); + backdrop-filter: blur(2px); + opacity: 0; + transition: + display 0.2s allow-discrete, + overlay 0.2s allow-discrete, + opacity 0.2s; + } + + &[open] { + opacity: 1; + transform: translateY(0) scale(1); + } + + &[open]::backdrop { + opacity: 1; + } + + @starting-style { + &[open], + &[open]::backdrop { + opacity: 0; + } + + &[open] { + transform: translateY(12px) scale(0.98); + } + } +} + +@include media('xs') { + #link-guard-dialog { + width: calc(100% - 2rem); + + .guard-content { + padding: 1rem; + + .actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .cancel-btn, + .confirm-btn { + width: 100%; + min-width: 0; + } + } + } +} diff --git a/assets/css/_page/_index.scss b/assets/css/_page/_index.scss index 20187772..3096e912 100644 --- a/assets/css/_page/_index.scss +++ b/assets/css/_page/_index.scss @@ -41,7 +41,8 @@ @import '_single'; @import '_special'; @import '_archive'; -@import 'home'; @import '404'; +@import 'home'; +@import 'link'; @import 'offline'; @import "patch"; diff --git a/assets/css/_page/_link.scss b/assets/css/_page/_link.scss new file mode 100644 index 00000000..634ade52 --- /dev/null +++ b/assets/css/_page/_link.scss @@ -0,0 +1,19 @@ +#content-link { + margin: auto 0; + + .card { + width: MIN(600px, calc(100% - 2rem)); + box-sizing: border-box; + margin: 0 auto; + @include link-guard-content; + + @include media('xs') { + margin-inline: 0; + padding: 1rem; + } + } + + body:has(&) { + @include set-fixit-var(global-background-color, fixit-var(header-background-color)); + } +} diff --git a/assets/css/_page/_single/_toc.scss b/assets/css/_page/_single/_toc.scss index b4da861a..ad0f018d 100644 --- a/assets/css/_page/_single/_toc.scss +++ b/assets/css/_page/_single/_toc.scss @@ -216,11 +216,6 @@ } #toc-dialog { - transition: - display 0.2s allow-discrete, - overlay 0.2s allow-discrete, - translate 0.2s, - opacity 0.2s 0.4s; opacity: 0; translate: 100vw 0; width: MIN(calc(100% - 4rem), 460px); @@ -231,6 +226,11 @@ border: none; border-left: 1px solid fixit-var(global-border-color-light); background-color: fixit-var(global-background-color); + transition: + display 0.2s allow-discrete, + overlay 0.2s allow-discrete, + translate 0.2s, + opacity 0.2s 0.4s; .toc { max-width: 100%; @@ -255,11 +255,11 @@ &::backdrop { background-color: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); + opacity: 0; transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete, opacity 0.2s 0.4s; - opacity: 0; } &[open], @@ -287,7 +287,3 @@ } } } - -html:has(#toc-dialog[open]) { - overflow: hidden; -} diff --git a/assets/js/page/link.js b/assets/js/page/link.js new file mode 100644 index 00000000..95d1e833 --- /dev/null +++ b/assets/js/page/link.js @@ -0,0 +1,44 @@ +/** + * for link redirection page + */ + +import { createCopyText } from '../utils/common'; + +const copyText = createCopyText(); + +function initLinkGuard() { + const params = new URLSearchParams(window.location.search); + const target = params.get('target'); + const targetElement = document.querySelector('.target'); + const copyBtn = document.querySelector('.copy-btn'); + const confirmBtn = document.querySelector('.confirm-btn'); + + if (target) { + targetElement.textContent = target; + copyBtn.disabled = false; + confirmBtn.disabled = false; + + copyBtn.addEventListener('click', () =>{ + copyText(target).then(() => { + copyBtn.toggleAttribute('data-copied', true); + setTimeout(() => { + copyBtn.toggleAttribute('data-copied', false); + }, 2000); + }, () => { + console.error('Clipboard write failed!', 'Your browser does not support clipboard API!'); + }); + }); + + confirmBtn.addEventListener('click', () => { + window.location.href = target; + }); + } else { + targetElement.textContent = 'Invalid target URL'; + } +} + +if (document.readyState !== 'loading') { + initLinkGuard(); +} else { + document.addEventListener('DOMContentLoaded', initLinkGuard, false); +} diff --git a/assets/js/theme.js b/assets/js/theme.js index 1868d209..c2e15a9f 100644 --- a/assets/js/theme.js +++ b/assets/js/theme.js @@ -1578,6 +1578,77 @@ class FixIt { this.initFootnotes(); } + /** + * Initialize link guard dialog for links with target="_blank" and data-guard="modal" + * @param {Element} target - The target element to initialize within (optional, defaults to document) + */ + initLinkGuardDialog(target = document) { + const dialog = document.getElementById('link-guard-dialog'); + if (!dialog) return; + + const $target = dialog.querySelector('.target'); + const $copy = dialog.querySelector('.copy-btn'); + const $confirm = dialog.querySelector('.confirm-btn'); + const $cancel = dialog.querySelector('.cancel-btn'); + + const _closeDialog = () => { + if (dialog.open) dialog.close(); + dialog._target = null; + if ($target) { + $target.textContent = '-'; + } + }; + + if (!dialog.dataset.init) { + dialog.dataset.init = 'true'; + + $confirm?.addEventListener('click', () => { + if (dialog._target) { + window.open(dialog._target, '_blank', 'noopener,noreferrer'); + } + _closeDialog(); + }); + + $cancel?.addEventListener('click', _closeDialog); + + $copy?.addEventListener('click', () => { + const textToCopy = dialog._target || ''; + if (!textToCopy) return; + copyText(textToCopy).then(() => { + $copy.toggleAttribute('data-copied', true); + window.setTimeout(() => { + $copy.toggleAttribute('data-copied', false); + }, 2000); + }); + }); + + dialog.addEventListener('click', (e) => { + if (e.target === dialog) _closeDialog(); + }); + } + + forEach(target.querySelectorAll('a[target="_blank"][data-guard="modal"]:not([data-init])'), ($link) => { + $link.dataset.init = 'true'; + $link.addEventListener('click', (e) => { + e.preventDefault(); + let target = $link.href; + try { + const guardUrl = new URL($link.href); + target = guardUrl.searchParams.get('target') || target; + } catch (err) { + // Ignore malformed URLs and fall back to the original href. + } + + dialog._target = target; + if ($target) { + $target.textContent = target; + } + dialog.showModal(); + document.activeElement?.blur(); + }, false); + }); + } + /** * Helper method to initialize content components * @param {Element} target - The target element (optional, defaults to document) @@ -1606,6 +1677,7 @@ class FixIt { this.initMathJax(); this.initJsonViewer(); this.initTabEvents(target); + this.initLinkGuardDialog(target); FileTree.init(target); window.FixItMermaid?.init?.(); window.FixItAPlayer?.init?.(); diff --git a/hugo.toml b/hugo.toml index b097968f..8456e26d 100644 --- a/hugo.toml +++ b/hugo.toml @@ -341,6 +341,16 @@ isHTML = true permalinkable = true notAlternative = true +# Fixit 1.0.0 | NEW Options to make output /link/index.html file +[outputFormats.link] +path = "link" +baseName = "index" +mediaType = "text/html" +isPlainText = false +isHTML = true +permalinkable = true +notAlternative = true + # FixIt 0.3.0 | NEW Options to make output readme.md file [outputFormats.readme] baseName = "readme" @@ -372,7 +382,7 @@ permalinkable = true # ------------------------------------------------------------------------------------- # Options to make hugo output files, the optional values are below: -# home = ["html", "rss", "archives", "offline", "readme", "baidu_urls", "search"] +# home = ["html", "rss", "archives", "offline", "link", "search", "readme", "baidu_urls"] # page = ["html", "markdown"] # section = ["html", "rss"] # taxonomy = ["html"] @@ -384,6 +394,7 @@ home = [ "rss", "archives", "offline", + "link", "search" ] page = [ @@ -434,8 +445,6 @@ dateFormat = "2006-01-02" images = [] # FixIt 0.2.12 | NEW enable PWA enablePWA = false -# FixIt 0.2.14 | NEW whether to add external Icon for external links automatically -externalIcon = false # FixIt 0.3.13 | NEW whether to capitalize titles capitalizeTitles = true # FixIt 0.3.0 | NEW whether to add site title to the title of every page @@ -1129,6 +1138,21 @@ optimise = false # e.g. ["no-optimize.jpg", "*.tiff", "images/exclude-*.webp"] blackList = [] +# FixIt 1.0.0 | NEW Link config +[params.link] +# whether to add external Icon for external links automatically +external_icon = true + +# external link guard config +[params.link.guard] +enable = false +# external link guard mode, available modes: ["modal", "redirect"] +# in "modal" mode, a dialog will pop up to ask for confirmation when users click external links +# in "redirect" mode, users will be redirected to an intermediate page before visiting external links +mode = "modal" +# allowed domains (exact host or parent domain) will skip confirmation +allow_domains = [] + # FixIt 0.4.0 | NEW Code block wrapper config # you can override the global config via Markdown attributes, for example: # ```lang {mode="mac", maxShownLines=5} @@ -1279,6 +1303,37 @@ collectionList = false # FixIt 0.3.0 | NEW whether to enable collection navigation at the end of the post collectionNavigation = false +# FixIt 0.4.6 | NEW External link guard config +[params.page.externalLinkGuard] +# whether to enable external link guard for post content +enable = false +# scope: +# - "content": only post content area (e.g. #content and decryptor content) +# - "site": all external links on the whole site +scope = "content" +# mode: "modal" (current page dialog) | "redirect" (intermediate page) +mode = "modal" +# allowed domains (exact host or parent domain) will skip confirmation +allow_domains = [] +# blocked URL schemes for security +block_schemes = [ + "javascript", + "data", + "file" +] +# how to open target link after confirm: +# - "follow": follow original link behavior +# - open in new tab when link has target="_blank" +# - otherwise open in current tab +# - "newtab": always open in a new tab +# - "self": always open in current tab +# note: Ctrl/Cmd/Shift + click is treated as force-new-tab and has higher priority than the policy above. +new_tab_policy = "follow" +# whether to show full URL in dialog +show_full_url = true +# custom message text, empty to use i18n default +message = "" + # FixIt 0.2.15 | NEW Repost config [params.page.repost] enable = false diff --git a/i18n/de.toml b/i18n/de.toml index 02aa17ae..26f0335c 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -183,6 +183,13 @@ other = "Offline" other = "Sie sind nicht mit dem Internet verbunden, es stehen nur zwischengespeicherte Seiten zur Verfügung." # === offline === +# === link redirection === +[linkRedirection] +title = "Hinweis zur Weiterleitung" +message = "Sie sind dabei, {{ .Title }} zu verlassen. Bitte achten Sie auf die Sicherheit Ihres Kontos und Vermögens." +confirm = "Weiter besuchen" +# === link redirection === + # === GitHub Alert === [alert] note = "Hinweis" diff --git a/i18n/en.toml b/i18n/en.toml index f883ae09..8a921585 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -183,6 +183,13 @@ other = "Offline" other = "You are not connected to the Internet, only cached pages will be available." # === offline === +# === link redirection === +[linkRedirection] +title = "Redirection Notice" +message = "You are about to leave {{ .Title }}, please be aware of your account and property security." +confirm = "Continue visiting" +# === link redirection === + # === GitHub Alert === [alert] note = "Note" diff --git a/i18n/es.toml b/i18n/es.toml index 832665a9..c80d62b3 100644 --- a/i18n/es.toml +++ b/i18n/es.toml @@ -183,6 +183,13 @@ other = "desconectado" other = "No está conectado a Internet, solo estarán disponibles las páginas almacenadas en caché." # === offline === +# === link redirection === +[linkRedirection] +title = "Aviso de redirección" +message = "Está a punto de salir de {{ .Title }}. Proteja la seguridad de su cuenta y sus bienes." +confirm = "Continuar visitando" +# === link redirection === + # === GitHub Alert === [alert] note = "Nota" diff --git a/i18n/fr.toml b/i18n/fr.toml index 6d009a28..6ef00792 100644 --- a/i18n/fr.toml +++ b/i18n/fr.toml @@ -183,6 +183,13 @@ other = "Hors ligne" other = "Vous n'êtes pas connecté à Internet, seules les pages mises en cache seront disponibles." # === offline === +# === link redirection === +[linkRedirection] +title = "Avis de redirection" +message = "Vous êtes sur le point de quitter {{ .Title }}. Veuillez protéger la sécurité de votre compte et de vos biens." +confirm = "Continuer la visite" +# === link redirection === + # === GitHub Alert === [alert] note = "Remarque" diff --git a/i18n/hi.toml b/i18n/hi.toml index d1bcc7da..6b0d976b 100644 --- a/i18n/hi.toml +++ b/i18n/hi.toml @@ -184,6 +184,13 @@ other = "ऑफलाइन" other = "आप इंटरनेट से कनेक्ट नहीं हैं, केवल कैश्ड पेज ही उपलब्ध होंगे।" # === offline === +# === link redirection === +[linkRedirection] +title = "रीडायरेक्शन सूचना" +message = "आप {{ .Title }} छोड़ने वाले हैं, कृपया अपने खाते और संपत्ति की सुरक्षा पर ध्यान दें।" +confirm = "देखना जारी रखें" +# === link redirection === + # === GitHub Alert === [alert] note = "ध्यान दें" diff --git a/i18n/it.toml b/i18n/it.toml index fc502f96..b7678cc9 100644 --- a/i18n/it.toml +++ b/i18n/it.toml @@ -183,6 +183,13 @@ other = "disconnesso" other = "Non sei connesso a Internet, saranno disponibili solo le pagine memorizzate nella cache." # === offline === +# === link redirection === +[linkRedirection] +title = "Avviso di reindirizzamento" +message = "Stai per lasciare {{ .Title }}. Presta attenzione alla sicurezza del tuo account e dei tuoi beni." +confirm = "Continua a visitare" +# === link redirection === + # === GitHub Alert === [alert] note = "Nota" diff --git a/i18n/ja.toml b/i18n/ja.toml index 8a9f5615..bb5b50d8 100644 --- a/i18n/ja.toml +++ b/i18n/ja.toml @@ -179,6 +179,13 @@ other = "オフライン" other = "インターネットに接続されていません。キャッシュされたページのみが利用可能です。" # === offline === +# === link redirection === +[linkRedirection] +title = "リダイレクト通知" +message = "{{ .Title }} から離れようとしています。アカウントと資産の安全にご注意ください。" +confirm = "閲覧を続ける" +# === link redirection === + # === GitHub Alert === [alert] note = "注意" diff --git a/i18n/ko.toml b/i18n/ko.toml index ef9fa258..963e46fc 100644 --- a/i18n/ko.toml +++ b/i18n/ko.toml @@ -179,6 +179,13 @@ other = "오프라인" other = "인터넷에 연결되어 있지 않으며, 캐시된 페이지만 사용할 수 있습니다." # === offline === +# === link redirection === +[linkRedirection] +title = "리디렉션 안내" +message = "{{ .Title }}에서 벗어나려고 합니다. 계정과 자산 보안에 유의해 주세요." +confirm = "계속 방문" + +# === link redirection === # === GitHub Alert === [alert] note = "노트" diff --git a/i18n/pl.toml b/i18n/pl.toml index af4388b7..c485869f 100644 --- a/i18n/pl.toml +++ b/i18n/pl.toml @@ -183,6 +183,13 @@ other = "Offline" other = "Nie masz połączenia z Internetem, dostępne będą tylko strony z pamięci podręcznej." # === offline === +# === link redirection === +[linkRedirection] +title = "Powiadomienie o przekierowaniu" +message = "Za chwilę opuścisz {{ .Title }}. Zadbaj o bezpieczeństwo konta i swoich środków." +confirm = "Kontynuuj odwiedzanie" +# === link redirection === + # === GitHub Alert === [alert] note = "Notatka" diff --git a/i18n/pt-BR.toml b/i18n/pt-BR.toml index a40abb9c..b97d9fde 100644 --- a/i18n/pt-BR.toml +++ b/i18n/pt-BR.toml @@ -184,6 +184,13 @@ other = "Offline" other = "Você não está conectado à Internet, apenas as páginas em cache estarão disponíveis." # === offline === +# === link redirection === +[linkRedirection] +title = "Aviso de redirecionamento" +message = "Você está prestes a sair de {{ .Title }}. Fique atento à segurança da sua conta e dos seus bens." +confirm = "Continuar visitando" +# === link redirection === + # === GitHub Alert === [alert] note = "Nota" diff --git a/i18n/ro.toml b/i18n/ro.toml index 0b5d05cc..04b6f051 100644 --- a/i18n/ro.toml +++ b/i18n/ro.toml @@ -183,6 +183,13 @@ other = "Deconectat" other = "Nu sunteți conectat la Internet, vor fi disponibile doar paginile stocate în cache." # === offline === +# === link redirection === +[linkRedirection] +title = "Notificare de redirecționare" +message = "Urmează să părăsiți {{ .Title }}. Vă rugăm să acordați atenție securității contului și bunurilor dvs." +confirm = "Continuă vizitarea" +# === link redirection === + # === GitHub Alert === [alert] note = "Notă" diff --git a/i18n/ru.toml b/i18n/ru.toml index c28b975f..b8932b88 100644 --- a/i18n/ru.toml +++ b/i18n/ru.toml @@ -183,6 +183,13 @@ other = "Не в сети" other = "Вы не подключены к интернету, будут доступны только кешированные страницы." # === offline === +# === link redirection === +[linkRedirection] +title = "Уведомление о перенаправлении" +message = "Вы собираетесь покинуть {{ .Title }}. Пожалуйста, обратите внимание на безопасность вашего аккаунта и имущества." +confirm = "Продолжить посещение" +# === link redirection === + # === GitHub Alert === [alert] note = "Примечание" diff --git a/i18n/sr.toml b/i18n/sr.toml index 14d0343f..84eac1ce 100644 --- a/i18n/sr.toml +++ b/i18n/sr.toml @@ -183,6 +183,13 @@ other = "Оффлине" other = "Нисте повезани на Интернет, биће доступне само кеширане странице." # === offline === +# === link redirection === +[linkRedirection] +title = "Обавештење о преусмеравању" +message = "Управо ћете напустити {{ .Title }}. Обратите пажњу на безбедност свог налога и имовине." +confirm = "Настави посету" +# === link redirection === + # === GitHub Alert === [alert] note = "Напомена" diff --git a/i18n/vi.toml b/i18n/vi.toml index fb3d2f4a..f41831ec 100644 --- a/i18n/vi.toml +++ b/i18n/vi.toml @@ -182,6 +182,13 @@ other = "ngoại tuyến" other = "Bạn chưa kết nối với Internet, chỉ các trang được lưu trong bộ nhớ cache sẽ khả dụng." # === offline === +# === link redirection === +[linkRedirection] +title = "Thông báo chuyển hướng" +message = "Bạn sắp rời khỏi {{ .Title }}, vui lòng chú ý an toàn cho tài khoản và tài sản của mình." +confirm = "Tiếp tục truy cập" +# === link redirection === + # === GitHub Alert === [alert] note = "Ghi chú" diff --git a/i18n/zh-CN.toml b/i18n/zh-CN.toml index 3cb58015..05f90eaf 100644 --- a/i18n/zh-CN.toml +++ b/i18n/zh-CN.toml @@ -179,6 +179,13 @@ other = "离线" other = "你没有连接到 Internet,只有缓存的页面可用。" # === offline === +# === link redirection === +[linkRedirection] +title = "跳转提示" +message = "即将离开{{ .Title }},请注意账号财产安全。" +confirm = "继续访问" +# === link redirection === + # === GitHub Alert === [alert] note = "注意" diff --git a/i18n/zh-TW.toml b/i18n/zh-TW.toml index 6705d72e..121ccf53 100644 --- a/i18n/zh-TW.toml +++ b/i18n/zh-TW.toml @@ -179,6 +179,13 @@ other = "離線" other = "你沒有連接到 Internet,只有緩存的頁面可用。" # === offline === +# === link redirection === +[linkRedirection] +title = "跳轉提示" +message = "即將離開{{ .Title }},請注意帳號財產安全。" +confirm = "繼續訪問" +# === link redirection === + # === GitHub Alert === [alert] note = "注意" diff --git a/layouts/_markup/render-link.html b/layouts/_markup/render-link.html index 26b1ea68..c53d175b 100644 --- a/layouts/_markup/render-link.html +++ b/layouts/_markup/render-link.html @@ -2,5 +2,5 @@ {{- with dict "Path" $destination | partial "function/resource.html" -}} {{- $destination = .RelPermalink -}} {{- end -}} -{{- $options := dict "Destination" $destination "Title" .Title "Content" .Text "ExternalIcon" .Page.Site.Params.externalIcon -}} +{{- $options := dict "Destination" $destination "Title" .Title "Content" .Text -}} {{- partial "plugin/link.html" $options -}} diff --git a/layouts/_partials/_debug/dump.html b/layouts/_partials/_debug/dump.html new file mode 100644 index 00000000..6f6de489 --- /dev/null +++ b/layouts/_partials/_debug/dump.html @@ -0,0 +1,10 @@ +{{- /* + This is a debug partial that dumps the current context. + @example + {{- partial "_debug/dump.html" . -}} +*/ -}} +{{- $debug := (site.Store.Get "devOpts").debug -}} + +{{- if $debug | and hugo.IsServer -}} +
{{ debug.Dump . }}
+{{- end -}} diff --git a/layouts/_partials/_debug/template-call-stack.html b/layouts/_partials/_debug/template-call-stack.html index f9d5543a..0f91b13f 100644 --- a/layouts/_partials/_debug/template-call-stack.html +++ b/layouts/_partials/_debug/template-call-stack.html @@ -1,6 +1,7 @@ {{- /* Render template call stack for debugging purpose. - Call the partial from any template: {{- partial "_debug/template-call-stack.html" . -}} + @example + {{- partial "_debug/template-call-stack.html" . -}} */ -}} {{- $debug := (site.Store.Get "devOpts").debug -}} diff --git a/layouts/_partials/function/get-file-tree.html b/layouts/_partials/function/get-file-tree.html index 192c4102..08b2dac6 100644 --- a/layouts/_partials/function/get-file-tree.html +++ b/layouts/_partials/function/get-file-tree.html @@ -84,7 +84,7 @@ {{- end -}} {{- /* Build children recursively */ -}} - {{- if $_debug -}}{{- warnf "> %v:" $rootLabel -}}{{- end -}} + {{- if $_debug -}}{{- warnf "[file-tree]> %v:" $rootLabel -}}{{- end -}} {{- $childOptions := dict "path" $root "deep" (add $deep 1) "level" $level "ignoreList" $ignoreList "isRecursive" true -}} {{- $children := partial "function/get-file-tree.html" $childOptions -}} diff --git a/layouts/_partials/layout/footer.html b/layouts/_partials/layout/footer.html index 862501fb..326806fa 100644 --- a/layouts/_partials/layout/footer.html +++ b/layouts/_partials/layout/footer.html @@ -42,7 +42,7 @@ {{- /* Author */ -}} {{- if ne $footerConfig.author false -}} - {{ partial "plugin/link.html" (dict "Destination" ($.Site.Params.author.link | default (relLangURL "")) "Content" .Site.Params.author.name) -}} + {{ partial "plugin/link.html" (dict "Destination" ($.Site.Params.author.link | default (relLangURL "")) "Content" .Site.Params.author.name "ExternalIcon" false) -}} {{- end -}} diff --git a/layouts/_partials/layout/widgets.html b/layouts/_partials/layout/widgets.html index c87bdb28..103bbf8a 100644 --- a/layouts/_partials/layout/widgets.html +++ b/layouts/_partials/layout/widgets.html @@ -36,7 +36,7 @@ {{- if $githubCorner.enable -}} {{- with $githubCorner -}} {{- $githubImage := `` -}} - {{- $options := dict "Destination" .Permalink "Title" .Title "Content" $githubImage "Class" (cond (eq .Position "left") "left" "right" | printf "github-corner %v d-none-mobile") "Noreferrer" false -}} + {{- $options := dict "Destination" .Permalink "Title" .Title "Content" $githubImage "ExternalIcon" false "Class" (cond (eq .Position "left") "left" "right" | printf "github-corner %v d-none-mobile") "Noreferrer" false -}} {{- partial "plugin/link.html" $options -}} {{- end -}} {{- end -}} @@ -65,6 +65,26 @@ {{- end -}} {{- end -}} + {{- /* Link Guard Dialog */ -}} + {{- if .Site.Params.link.guard.enable -}} + +
+

{{ T "linkRedirection.title" }}

+

{{ dict "Title" .Site.Title | T "linkRedirection.message" }}

+
+

-

+ +
+
+ + +
+
+
+ {{- end -}} + {{- /* Custom widgets */ -}} {{- block "custom-widgets" . }}{{ end -}} diff --git a/layouts/_partials/plugin/link.html b/layouts/_partials/plugin/link.html index 48dad275..60fca7bc 100644 --- a/layouts/_partials/plugin/link.html +++ b/layouts/_partials/plugin/link.html @@ -1,88 +1,157 @@ -{{- $rel := "" -}} -{{- $external := false -}} +{{- /* + Link rendering partial for regular and card-style links. + - Auto-detects external links and applies target/rel policies. + - Supports optional icon, download hint, and external icon display. + - Supports card mode with URL meta and favicon fallback. + @param {String} .Destination target URL + @param {String} [.Content] link label HTML + @param {String} [.Title] title attribute + @param {String} [.Class] custom CSS classes + @param {Boolean} [.Newtab] force external behavior + @param {Boolean} [.Noreferrer] whether to include noreferrer (default: true) + @param {Boolean} [.ExternalIcon] whether to show external-link icon + @param {Object} [.Icon] icon params for plugin/icon.html + @param {Boolean} [.Card] whether to render card link layout + @param {String} [.CardIcon] card icon URL or icon class + @param {String} [.Download] download attribute value +*/ -}} + +{{- /* Regular link rendering */ -}} +{{- define "render-link-regular" -}} + {{- $ctx := .ctx -}} + {{- $config := .config -}} + {{- $isExternal := .isExternal -}} + {{- $externalIcon := $ctx.ExternalIcon | default $config.externalIcon -}} + {{- with $ctx.Icon -}} + {{- partial "plugin/icon.html" . }} + {{ end -}} + {{- with $ctx.Content -}} + {{- . | safeHTML -}} + {{- end -}} + {{- if + $externalIcon + | and $isExternal + | and (not (hasPrefix $ctx.Content ``)) + | and (not (strings.HasSuffix $ctx.Content `):`)) + -}} + {{- if $ctx.Download -}} + {{- dict "Class" "fa-solid fa-cloud-download-alt ms-1 text-secondary" | partial "plugin/icon.html" -}} + {{- else -}} + {{- dict "Class" "fa-solid fa-external-link-alt fa-xs ms-1 text-secondary" | partial "plugin/icon.html" -}} + {{- end -}} + {{- else if $ctx.Download }} + {{- dict "Class" "fa-solid fa-download ms-1 text-secondary" | partial "plugin/icon.html" -}} + {{- end -}} +{{- end -}} + +{{- /* Card link rendering */ -}} +{{- define "render-link-card" -}} + {{- $ctx := .ctx -}} + {{- $url := .url -}} + {{- $cardIcon := $ctx.CardIcon -}} + {{- if (not $cardIcon) | and $url.Host -}} + {{- with partial "function/get-remote-image.html" (dict "Src" (add "https://favicon.im/" $url.Host)) -}} + {{- $cardIcon = .RelPermalink -}} + {{- else with partial "function/get-remote-image.html" (dict "Src" (add "https://api.lruihao.cn/google/s2/favicons?sz=64&domain=" $url.Host)) -}} + {{- $cardIcon = .RelPermalink -}} + {{- end -}} + {{- end -}} + + + + + + {{- with $ctx.Icon -}} + {{- partial "plugin/icon.html" . }} + {{ end -}} + {{- with $ctx.Content -}} + {{- . | safeHTML -}} + {{- end -}} + + + + + {{- $url.String | safeURL | strings.TrimPrefix (add $url.Scheme "://") -}} + + + + {{- if $ctx.Download -}} + + {{- else -}} + {{- with $cardIcon -}} + {{- if (strings.HasPrefix . "fa") -}} + + {{- else -}} + card image + {{- end -}} + {{- else -}} + + {{- end -}} + {{- end -}} + +{{- end -}} + +{{- $blockSchemes := slice "javascript" "mailto" "tel" "file" "data" "vscode" "trae" -}} +{{- $config := partial "function/snake2camel.html" site.Params.link -}} +{{- $rel := .Rel | default "" -}} +{{- $isExternal := false -}} {{- $noreferrer := true -}} {{- if eq .Noreferrer false -}} {{- $noreferrer = false -}} {{- end -}} {{- $url := urls.Parse .Destination -}} -{{- if $url.IsAbs | or .Newtab -}} - {{- $rel = cond $noreferrer "external nofollow noopener noreferrer" "external nofollow" -}} - {{- $external = true -}} +{{/* {{ partial "_debug/dump" $url }} */}} +{{- if $url.IsAbs | and (not (in $blockSchemes $url.Scheme)) -}} + {{- $isExternal = not (strings.HasPrefix .Destination site.BaseURL) -}} +{{- end -}} +{{- if $isExternal -}} + {{- $rel = add $rel " external nofollow" -}} + {{- if $noreferrer -}} + {{- $rel = add $rel " noopener noreferrer" -}} + {{- end -}} {{- end -}} {{- $class := .Class | default "" -}} {{- if eq .Card true -}} {{- $class = trim (printf "%v %v" "card-link" $class) " " -}} {{- end -}} - - {{- if ne .Card true -}} - {{- with .Icon -}} - {{- partial "plugin/icon.html" . }} - {{ end -}} - {{- with .Content -}} - {{- . | safeHTML -}} - {{- end -}} - {{- if - (.ExternalIcon | default false) - | and $external | and (eq (hasPrefix .Content ``) | or (strings.HasSuffix .Content `):`)) false) - -}} - {{- if .Download -}} - {{- dict "Class" "fa-solid fa-cloud-download-alt ms-1 text-secondary" | partial "plugin/icon.html" -}} - {{- else -}} - {{- dict "Class" "fa-solid fa-external-link-alt fa-xs ms-1 text-secondary" | partial "plugin/icon.html" -}} - {{- end -}} - {{- else if .Download }} - {{- dict "Class" "fa-solid fa-download ms-1 text-secondary" | partial "plugin/icon.html" -}} - {{- end -}} - {{- else -}} - {{- $cardIcon := .CardIcon -}} - {{- if (not $cardIcon) | and $url.Host -}} - {{- with partial "function/get-remote-image.html" (dict "Src" (add "https://favicon.im/" $url.Host)) -}} - {{- $cardIcon = .RelPermalink -}} - {{- else with partial "function/get-remote-image.html" (dict "Src" (add "https://api.lruihao.cn/google/s2/favicons?sz=64&domain=" $url.Host)) -}} - {{- $cardIcon = .RelPermalink -}} - {{- end -}} - {{- end -}} - - - - - {{- with .Icon -}} - {{- partial "plugin/icon.html" . }} - {{ end -}} - {{- with .Content -}} - {{- . | safeHTML -}} - {{- end -}} - - - - - {{- $u := urls.Parse .Destination -}} - {{- $u.String | safeURL | strings.TrimPrefix (add $u.Scheme "://") -}} - - - - {{- if .Download -}} - - {{- else -}} - {{- with $cardIcon -}} - {{- if (strings.HasPrefix . "fa") -}} - - {{- else -}} - card image - {{- end -}} - {{- else -}} - - {{- end -}} - {{- end -}} - +{{- /* External link guard */ -}} +{{- $isGuarded := false -}} +{{- if + $config.guard.enable + | and $isExternal + | and (not (in $config.guard.allowDomains $url.Host)) +-}} + {{- $isGuarded = true -}} +{{- end -}} + +{{- $destination := cond $isGuarded (add (relLangURL "link") "?target=" (urlquery .Destination)) .Destination -}} +{{- $attrs := printf `href="%s"` ($destination | safeURL) -}} +{{- with .Title -}} + {{- $attrs = printf ` title="%s"` . | add $attrs -}} +{{- end -}} +{{- if $isExternal | or .Newtab -}} + {{- $attrs = add $attrs ` target="_blank"` -}} +{{- end -}} +{{- with $rel -}} + {{- $attrs = printf ` rel="%s"` (trim . " ") | add $attrs -}} +{{- end -}} +{{- with $class -}} + {{- $attrs = printf ` class="%s"` . | add $attrs -}} +{{- end -}} +{{- with .Download -}} + {{- $attrs = printf ` download="%s"` . | add $attrs -}} +{{- end -}} +{{- if $isGuarded | and (eq $config.guard.mode "modal") -}} + {{- $attrs = add $attrs ` data-guard="modal"` -}} +{{- end -}} + + + {{- if eq .Card true -}} + {{- template "render-link-card" (dict "ctx" . "url" $url) -}} + {{- else -}} + {{- template "render-link-regular" (dict "ctx" . "config" $config "isExternal" $isExternal) -}} {{- end -}} {{- /* EOF */ -}} diff --git a/layouts/_partials/plugin/social.html b/layouts/_partials/plugin/social.html index 1d75597f..356006dc 100644 --- a/layouts/_partials/plugin/social.html +++ b/layouts/_partials/plugin/social.html @@ -8,4 +8,4 @@ {{- end -}} {{- $destination = printf (string $template) .Id -}} {{- end -}} -{{- dict "Destination" $destination "Rel" "me" | merge . | partial "plugin/link.html" -}} +{{- dict "Destination" $destination "Rel" "me" "ExternalIcon" false | merge . | partial "plugin/link.html" -}} diff --git a/layouts/_partials/single/footer.html b/layouts/_partials/single/footer.html index 9814a6cb..4f104927 100644 --- a/layouts/_partials/single/footer.html +++ b/layouts/_partials/single/footer.html @@ -81,7 +81,8 @@ {{- $linkOptions = $linkOptions | append $options -}} {{- end -}} {{- range $linkOptions -}} - {{ partial "plugin/link.html" . }} + {{- $commonOptions := dict "ExternalIcon" false -}} + {{ partial "plugin/link.html" (. | merge $commonOptions) }} {{- end -}}