Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { LANGUAGE_NAMES } from "./i18n";

const { locale } = useI18n();

const logs = ref<string[]>([]);

function toggleLocale() {
locale.value = locale.value === "ja" ? "en" : "ja";
}

const originalLog = console.log;
console.log = (...args: unknown[]) => {
originalLog(...args);
Expand All @@ -26,9 +23,11 @@ console.log = (...args: unknown[]) => {
<header>
<div class="header-top">
<h1>link-interceptor</h1>
<button class="locale-btn" @click="toggleLocale">
{{ $t("locale.switch") }}
</button>
<select v-model="locale" class="locale-select">
<option v-for="(name, code) in LANGUAGE_NAMES" :key="code" :value="code">
{{ name }}
</option>
</select>
</div>
<nav>
<router-link to="/">{{ $t("nav.home") }}</router-link>
Expand Down Expand Up @@ -92,20 +91,25 @@ header h1 {
font-size: 1.5rem;
}

.locale-btn {
padding: 0.3rem 0.75rem;
.locale-select {
padding: 0.3rem 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
color: #333;
font-size: 0.8rem;
cursor: pointer;
outline: none;
}

.locale-btn:hover {
.locale-select:hover {
background: #f0f0f0;
}

.locale-select:focus {
border-color: #4361ee;
}

nav {
display: flex;
flex-wrap: wrap;
Expand Down
144 changes: 144 additions & 0 deletions playground/src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
export default {
nav: {
home: "Startseite",
internal: "Intern",
external: "Extern",
prevent: "Blockieren",
analytics: "Analytik",
confirm: "Bestätigen",
formGuard: "Form Guard",
security: "Sicherheit",
},
home: {
title: "link-interceptor",
description:
"Fängt alle Klicks auf {tag}-Tags in Ihrer SPA ab. Framework-unabhängiger Kern mit Vue-, React- und Svelte-Wrappern. Erfasst in der Capture-Phase und bietet Callbacks für interne/externe Links.",
install: "Installation",
basic: "Interaktive Demos",
useCases: "Anwendungsfälle",
console: "Konsole",
consoleDescription:
"Interceptor-Logs erscheinen im Konsolenpanel unten. Klicken Sie auf einen Link, um sie zu sehen.",
internalDesc: "Interne Links in router.push() umwandeln",
externalDesc: "URLs externer Links umschreiben",
preventDesc: "Link-Navigation blockieren",
analyticsDesc: "Link-Klicks verfolgen",
confirmDesc: "Bestätigungsdialog für externe Navigation",
formGuardDesc: "Navigation bei ungespeicherten Formularänderungen verhindern",
securityDesc: "Domain-Erlaubnisliste + automatisches rel-Attribut",
},
internal: {
title: "Interne Links",
description:
"Erfasst Klicks auf gleichherkunfts-{tag}-Tags mit onInternalLink und wandelt sie über router.push() in SPA-Routing um.",
normalLinks: "Normale HTML-Links (vom Plugin abgefangen)",
toHome: "Zur Startseite",
toExternal: "Zu Externen Links",
toPrevent: "Zu Blockieren",
vhtml: "Links in v-html (dynamisch generiertes HTML wird ebenfalls abgefangen)",
vhtmlContent:
'Dies ist Inhalt, der mit v-html gerendert wurde: <a href="/">Zurück zur Startseite</a> | <a href="/prevent">Blockieren ansehen</a>',
nested: "Verschachtelte Elemente",
nestedDesc: "Klicks auf Kindelemente innerhalb von {tag} werden ebenfalls erkannt",
nestedLink: "Dekorierter Link",
routerLink: "Koexistenz mit Router Link",
routerLinkDesc:
"<router-link> und einfache <a>-Tags funktionieren nebeneinander. Der Interceptor erfasst beide in der Capture-Phase. RouterLink prüft event.defaultPrevented und überspringt seine eigene Navigation, wenn der Interceptor sie bereits behandelt hat.",
routerLinkToHome: "router-link zur Startseite",
plainLinkToExternal: "einfaches <a> zu Externen Links",
routerLinkNote:
"Beide Links erscheinen in der Konsole — der Interceptor behandelt alle <a>-Klicks, unabhängig davon, ob sie von <router-link> oder einfachem HTML stammen.",
routerLinkGotcha: "Fallstrick: router-link replace",
routerLinkGotchaDesc:
"Der Interceptor erfasst auch Klicks auf <router-link replace>. Wenn der Callback ctx.preventDefault() und router.push() aufruft, wird die replace-Prop stillschweigend ignoriert — ein Verlaufseintrag wird hinzugefügt statt ersetzt.",
routerLinkReplaceBroken: "ohne Workaround — replace wird ignoriert (klicken, dann Zurück drücken zum Prüfen)",
routerLinkReplaceFixed: "mit data-no-intercept — replace funktioniert (klicken, dann Zurück drücken zum Vergleichen)",
routerLinkGotchaNote:
"Der erste Link hat keinen Workaround: Der Interceptor ruft preventDefault() + router.push() auf, daher geht replace verloren und ein Verlaufseintrag wird hinzugefügt. Der zweite Link hat data-no-intercept: Der Callback überspringt preventDefault(), sodass RouterLink die Navigation mit replace durchführt.",
routerLinkWorkaround:
"Workaround: Fügen Sie ein data-no-intercept-Attribut zu <router-link>-Elementen hinzu, die Props wie replace beibehalten müssen. Im Callback ctx.anchor.hasAttribute('data-no-intercept') prüfen und ctx.preventDefault() überspringen, damit RouterLink die Navigation selbst durchführt. Siehe main.ts für die Implementierung.",
},
external: {
title: "Externe Links",
description:
"Erfasst Klicks auf externe Links (andere Herkunft) mit onExternalLink. Diese Demo fügt automatisch den Parameter ?from=playground hinzu.",
externalLinks: "Externe Links (URL wird beim Klick umgeschrieben)",
note: 'Prüfen Sie die umgeschriebene URL in der Konsole. Links mit target="_blank" werden ebenfalls erfasst.',
modifierTest: "Modifikatortasten-Test",
modifierDesc:
"Versuchen Sie Strg/Cmd + Klick. Klicks mit Modifikatortasten werden übersprungen, das Verhalten des Browsers für neue Tabs wird respektiert.",
thisLink: "diesen Link",
},
prevent: {
title: "Navigation blockieren",
description:
"Rufen Sie ctx.preventDefault() im Callback auf, um die Link-Navigation abzubrechen.",
normalLink: "Normaler interner Link (navigiert)",
toHome: "Zur Startseite navigieren",
blockedLinks: "Blockierte Links (keine Navigation)",
blockedDesc:
"Die folgenden Links haben ein data-block-Attribut. Die Demo blockiert die Navigation in main.ts.",
blockedLink: "blocked.example.com (Klick navigiert nicht)",
blockedToast: "Navigation zu {url} blockiert",
},
analytics: {
title: "Analytik / Tracking",
description:
"Beispiel für das Auslösen von Analyse-Events bei Link-Klicks. Stellen Sie sich das Senden an GA4 oder Mixpanel vor.",
tryClick: "Versuchen Sie, auf diese Links zu klicken",
internalLink: "Interner Link (Seitennavigation)",
anotherDemo: "Zu einer anderen Demo-Seite",
collectedEvents: "Gesammelte Events",
time: "Zeit",
type: "Typ",
url: "URL",
noEvents: "Noch keine Events",
},
confirm: {
title: "Bestätigungsdialog",
description:
'Zeigt einen Bestätigungsdialog beim Klick auf einen externen Link. "Abbrechen" blockiert die Navigation, "OK" erlaubt sie.',
withConfirm: "Links mit Bestätigungsdialog",
withConfirmDesc: "Die folgenden Links haben ein data-confirm-Attribut.",
confirmSuffix: " (mit Bestätigung)",
withoutConfirm: "Links ohne Bestätigung (normales Verhalten)",
withoutConfirmSuffix: " (ohne Bestätigung)",
internalLink: "Interner Link (ohne Bestätigung)",
implementation: "Implementierungsbeispiel",
confirmPrompt: "Zu {hostname} navigieren?",
},
formGuard: {
title: "Form Guard",
description:
'Zeigt eine Warnung „Änderungen gehen verloren" beim Klick auf einen Link während der Formularbearbeitung. Der Dialog erscheint nur bei ungespeicherten Eingaben.',
formSection: "Formular (geben Sie etwas ein und klicken Sie dann auf einen Link)",
name: "Name",
namePlaceholder: "Max Mustermann",
email: "E-Mail",
emailPlaceholder: "max{'@'}example.com",
dirty: "Ungespeicherte Änderungen",
clean: "Keine Änderungen",
navLinks: "Navigationslinks",
navDesc: "Ein Bestätigungsdialog erscheint beim Klick mit ungespeicherten Formulareingaben.",
implementation: "Implementierungsbeispiel",
confirmLeave: "Änderungen gehen verloren. Trotzdem navigieren?",
},
security: {
title: "Sicherheit",
description:
"Sicherheitskontrollen für externe Links. Kombiniert eine Domain-Erlaubnisliste mit einem automatischen rel-Attribut.",
allowlist: "Erlaubnisliste",
allowlistDesc: "Erlaubte Domains: {domains}. Alle anderen sind blockiert.",
allowed: "Erlaubt",
blocked: "Blockiert",
relSection: "Automatisches rel-Attribut",
relDesc:
'Alle externen Links erhalten automatisch rel="noopener noreferrer". Prüfen Sie im Elements-Panel der DevTools.',
implementation: "Implementierungsbeispiel",
blockedAlert: "{hostname} ist blockiert",
},
console: {
title: "Konsole",
empty: "Klicken Sie auf einen Link, um Interceptor-Logs hier zu sehen",
},
};
5 changes: 1 addition & 4 deletions playground/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ export default {
formGuard: "Form Guard",
security: "Security",
},
locale: {
switch: "日本語",
},
home: {
home: {
title: "link-interceptor",
description:
"Intercept all {tag} tag clicks in your SPA. Framework-agnostic core with Vue, React, and Svelte wrappers. Captures at the capture phase and provides callbacks for internal/external links.",
Expand Down
144 changes: 144 additions & 0 deletions playground/src/i18n/es.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
export default {
nav: {
home: "Inicio",
internal: "Internos",
external: "Externos",
prevent: "Prevenir",
analytics: "Analítica",
confirm: "Confirmar",
formGuard: "Form Guard",
security: "Seguridad",
},
home: {
title: "link-interceptor",
description:
"Intercepta todos los clics en etiquetas {tag} en tu SPA. Núcleo independiente del framework con wrappers para Vue, React y Svelte. Captura en la fase de captura y proporciona callbacks para enlaces internos/externos.",
install: "Instalación",
basic: "Demos interactivas",
useCases: "Casos de uso",
console: "Consola",
consoleDescription:
"Los registros del interceptor aparecen en el panel de consola en la parte inferior. Haz clic en un enlace para verlos.",
internalDesc: "Convertir enlaces internos a router.push()",
externalDesc: "Reescribir URLs de enlaces externos",
preventDesc: "Bloquear la navegación de enlaces",
analyticsDesc: "Rastrear clics en enlaces",
confirmDesc: "Diálogo de confirmación para navegación externa",
formGuardDesc: "Prevenir la navegación con cambios no guardados",
securityDesc: "Lista de dominios permitidos + atributo rel automático",
},
internal: {
title: "Enlaces internos",
description:
"Captura clics en etiquetas {tag} del mismo origen con onInternalLink y los convierte en enrutamiento SPA mediante router.push().",
normalLinks: "Enlaces HTML normales (interceptados por el plugin)",
toHome: "Ir a Inicio",
toExternal: "Ir a Enlaces externos",
toPrevent: "Ir a Prevenir",
vhtml: "Enlaces en v-html (el HTML generado dinámicamente también es interceptado)",
vhtmlContent:
'Este es contenido renderizado con v-html: <a href="/">Volver a Inicio</a> | <a href="/prevent">Ver Prevenir</a>',
nested: "Elementos anidados",
nestedDesc: "Los clics en elementos hijos dentro de {tag} también son detectados",
nestedLink: "Enlace decorado",
routerLink: "Coexistencia con Router Link",
routerLinkDesc:
"<router-link> y las etiquetas <a> simples funcionan lado a lado. El interceptor captura ambos en la fase de captura. RouterLink verifica event.defaultPrevented y omite su propia navegación cuando el interceptor ya lo ha gestionado.",
routerLinkToHome: "router-link a Inicio",
plainLinkToExternal: "<a> simple a Enlaces externos",
routerLinkNote:
"Ambos enlaces aparecen en la consola — el interceptor maneja todos los clics en <a> independientemente de si provienen de <router-link> o HTML simple.",
routerLinkGotcha: "Trampa: router-link replace",
routerLinkGotchaDesc:
"El interceptor también captura clics de <router-link replace>. Si el callback llama a ctx.preventDefault() y router.push(), la prop replace se ignora silenciosamente — se añade una entrada al historial en lugar de reemplazar.",
routerLinkReplaceBroken: "sin solución — replace se ignora (haz clic, luego presiona Atrás para ver)",
routerLinkReplaceFixed: "con data-no-intercept — replace funciona (haz clic, luego presiona Atrás para comparar)",
routerLinkGotchaNote:
"El primer enlace no tiene solución: el interceptor llama a preventDefault() + router.push(), así que replace se pierde y se añade una entrada al historial. El segundo enlace tiene data-no-intercept: el callback omite preventDefault(), dejando que RouterLink maneje la navegación con replace intacto.",
routerLinkWorkaround:
"Solución: añadir un atributo data-no-intercept a los elementos <router-link> que necesiten preservar props como replace. En el callback, verificar ctx.anchor.hasAttribute('data-no-intercept') y omitir ctx.preventDefault() para que RouterLink maneje la navegación. Ver main.ts para la implementación.",
},
external: {
title: "Enlaces externos",
description:
"Captura clics de enlaces externos (diferente origen) con onExternalLink. Esta demo añade automáticamente el parámetro ?from=playground.",
externalLinks: "Enlaces externos (la URL se reescribe al hacer clic)",
note: 'Verifica la URL reescrita en la consola. Los enlaces con target="_blank" también son interceptados.',
modifierTest: "Prueba de teclas modificadoras",
modifierDesc:
"Prueba Ctrl/Cmd + Clic. Los clics con teclas modificadoras se omiten, respetando el comportamiento de nueva pestaña del navegador.",
thisLink: "este enlace",
},
prevent: {
title: "Prevenir navegación",
description:
"Llama a ctx.preventDefault() en el callback para cancelar la navegación del enlace.",
normalLink: "Enlace interno normal (navega)",
toHome: "Navegar a Inicio",
blockedLinks: "Enlaces bloqueados (sin navegación)",
blockedDesc:
"Los siguientes enlaces tienen un atributo data-block. La demo bloquea la navegación en main.ts.",
blockedLink: "blocked.example.com (el clic no navega)",
blockedToast: "Navegación bloqueada a {url}",
},
analytics: {
title: "Analítica / Seguimiento",
description:
"Ejemplo de disparo de eventos de analítica al hacer clic en enlaces. Imagina enviando a GA4 o Mixpanel.",
tryClick: "Prueba hacer clic en estos enlaces",
internalLink: "Enlace interno (navegación de página)",
anotherDemo: "Ir a otra página de demo",
collectedEvents: "Eventos recopilados",
time: "Hora",
type: "Tipo",
url: "URL",
noEvents: "Aún no hay eventos",
},
confirm: {
title: "Diálogo de confirmación",
description:
'Muestra un diálogo de confirmación al hacer clic en un enlace externo. "Cancelar" bloquea la navegación, "Aceptar" la permite.',
withConfirm: "Enlaces con diálogo de confirmación",
withConfirmDesc: "Los siguientes enlaces tienen un atributo data-confirm.",
confirmSuffix: " (con confirmación)",
withoutConfirm: "Enlaces sin confirmación (comportamiento normal)",
withoutConfirmSuffix: " (sin confirmación)",
internalLink: "Enlace interno (sin confirmación)",
implementation: "Ejemplo de implementación",
confirmPrompt: "¿Navegar a {hostname}?",
},
formGuard: {
title: "Form Guard",
description:
'Muestra una advertencia de "los cambios se perderán" al hacer clic en un enlace mientras se edita un formulario. El diálogo solo aparece cuando hay entradas no guardadas.',
formSection: "Formulario (escribe algo y luego haz clic en un enlace)",
name: "Nombre",
namePlaceholder: "Juan García",
email: "Correo electrónico",
emailPlaceholder: "juan{'@'}example.com",
dirty: "Cambios no guardados",
clean: "Sin cambios",
navLinks: "Enlaces de navegación",
navDesc: "Aparece un diálogo de confirmación al hacer clic con entradas de formulario no guardadas.",
implementation: "Ejemplo de implementación",
confirmLeave: "Los cambios se perderán. ¿Navegar de todos modos?",
},
security: {
title: "Seguridad",
description:
"Controles de seguridad para enlaces externos. Combina lista de dominios permitidos con atributo rel automático.",
allowlist: "Lista de permitidos",
allowlistDesc: "Dominios permitidos: {domains}. Todos los demás están bloqueados.",
allowed: "Permitido",
blocked: "Bloqueado",
relSection: "Atributo rel automático",
relDesc:
'Todos los enlaces externos obtienen automáticamente rel="noopener noreferrer". Verifica en el panel Elements de DevTools.',
implementation: "Ejemplo de implementación",
blockedAlert: "{hostname} está bloqueado",
},
console: {
title: "Consola",
empty: "Haz clic en un enlace para ver los registros del interceptor aquí",
},
};
Loading
Loading