From 77489543e731cd78acf27488b49ae9b76cae8b40 Mon Sep 17 00:00:00 2001 From: Aurelius Wendelken Date: Sun, 13 Sep 2026 02:25:45 +0200 Subject: [PATCH] refactor(links): standardize external links site-wide --- AGENTS.md | 1 + README.md | 4 ++ app/components/AppFooter.vue | 8 +-- app/components/AppHeader.vue | 2 +- app/components/EditorialPage.vue | 10 +-- app/components/EventCard.vue | 6 +- app/components/MeetingSection.vue | 2 +- app/components/SiteLink.vue | 12 ++++ app/components/TeamList.vue | 8 +-- app/components/content/LegalDetails.vue | 4 +- app/components/content/ProseA.vue | 15 ++-- app/error.vue | 4 +- app/pages/index.vue | 2 +- app/pages/projekte/[slug].vue | 12 ++-- i18n/locales/da.json | 3 +- i18n/locales/de.json | 3 +- i18n/locales/en.json | 3 +- package.json | 2 + pnpm-lock.yaml | 6 ++ shared/utils/links.ts | 32 +++++++++ tests/e2e/links.spec.ts | 55 ++++++++++++++ tests/links.test.ts | 96 +++++++++++++++++++++++++ vitest.config.ts | 4 ++ 23 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 app/components/SiteLink.vue create mode 100644 shared/utils/links.ts create mode 100644 tests/e2e/links.spec.ts create mode 100644 tests/links.test.ts diff --git a/AGENTS.md b/AGENTS.md index 05f7533..78d7eef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ Do not hand-edit or commit generated runtime/tooling directories: `.nuxt/`, `.ou - Use `usePageSeo` for localized titles/descriptions, OpenGraph/Twitter and WebPage data. `usePageSeo` also owns canonical and actual Content-based hreflang; sitemap uses the same translation groups. `app/app.vue` uses `useLocaleHead({ seo: false })` only for language attributes, plus Organization/WebSite data. The public translation index is derived from existing Content collections, with draft/future/noindex filtering and no invented language variants or x-default. Avoid competing head implementations. - Keep `site.url` authoritative (currently `https://oklabflensburg.de`), including absolute logo/social/schema URLs. Preserve BlogPosting and Event data where applicable, correct publication filters, `noindex` exclusions and XML escaping in `server/routes/`. Check rendered metadata, sitemap, RSS and robots when changing routes or SEO. +- Render ordinary Vue anchors through `SiteLink.vue`; use `NuxtLink` for localized application navigation. `shared/utils/links.ts` is the sole external-link detector, comparing HTTP(S) origins with `site.url`. External links always receive `target="_blank"` and `rel="noopener noreferrer"` (preserve meaningful tokens such as `me`); internal/canonical absolute links, locale switching, breadcrumbs, fragments, mail and phone links stay in the same tab. `ProseA.vue` applies the same rule centrally to Content and preserves translation-aware routing, queries and fragments. Keep the localized accessible new-tab description and link regression tests. - Use semantic HTML, one page `h1`, logical headings, descriptive links and appropriate alt text (empty for decoration). Keep the skip link, visible focus, route announcer, keyboard operation and mobile menu Escape/focus return. Use ARIA only where native semantics are insufficient; never communicate essential meaning by colour alone. - Preserve readable contrast, at least 44 px high primary touch targets, mobile reflow and usability at 200% zoom. Do not hide overflowing content to pass a layout check. Existing reflow tests cover 320 CSS px and 720 CSS px (the layout width of a 1440 px window at 200% zoom); report this distinction accurately. diff --git a/README.md b/README.md index 742b569..3a52285 100644 --- a/README.md +++ b/README.md @@ -139,3 +139,7 @@ Feature-Branch erstellen, Änderungen klein und nachvollziehbar halten, die zum ## Lizenz und Quellen Quellcode: [CC0 1.0](LICENSE). Manrope: [SIL Open Font License](OFL.txt). Projektbeschreibungen, soweit von [Code for Germany](https://codefor.de/flensburg/) adaptiert: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), mit konkreter Quellenangabe in jeder Projektdatei. Vorhandene Marken und Projektbilder behalten ihre ursprünglichen Rechte; die Code-Lizenz ist keine zusätzliche Markenlizenz. + +## Externe Links + +Externe HTTP(S)-Links öffnen über `SiteLink.vue` in einem neuen Tab mit `noopener noreferrer`; zusätzliche Werte wie `rel="me"` bleiben erhalten. `shared/utils/links.ts` vergleicht die Ziel-Origin mit `site.url`. Nuxt Content nutzt dieselbe Regel in `ProseA.vue`; interne und gleichursprüngliche absolute Links bleiben im selben Tab. Übersetzte Routen, Query-Parameter und Fragmente bleiben erhalten. Die Komponenten- und Browsertests prüfen diese Regeln in DE/DA/EN. diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index dfbcaf0..9862845 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -11,10 +11,10 @@ const localePath = useLocalePath() diff --git a/app/components/content/LegalDetails.vue b/app/components/content/LegalDetails.vue index 9e3be53..b751889 100644 --- a/app/components/content/LegalDetails.vue +++ b/app/components/content/LegalDetails.vue @@ -33,11 +33,11 @@ const details = computed(() => optionalLegalFields.filter((field) =>
{{ $t('legal.email') }}
-
{{ contact.email }}{{ contact.email }}
+
{{ contact.email }}{{ contact.email }}
{{ $t('legal.phone') }}
-
{{ contact.phone }}{{ contact.phone }}
+
{{ contact.phone }}{{ contact.phone }}
{{ $t(`legal.${field}`) }}
diff --git a/app/components/content/ProseA.vue b/app/components/content/ProseA.vue index c4d77d0..1a29f54 100644 --- a/app/components/content/ProseA.vue +++ b/app/components/content/ProseA.vue @@ -1,22 +1,25 @@ diff --git a/app/error.vue b/app/error.vue index 22b3c1d..94fde51 100644 --- a/app/error.vue +++ b/app/error.vue @@ -8,10 +8,10 @@ useSeoMeta({ title: () => t('error.title'), robots: 'noindex, follow' }) diff --git a/app/pages/index.vue b/app/pages/index.vue index d501fb7..c09c856 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -27,7 +27,7 @@ usePageSeo(() => `${t('hero.line1')} ${t('hero.line2')}`, () => t('hero.intro'))
{{ site.meeting.city }}

{{ $t('hero.caption') }}

- © OpenStreetMap + © OpenStreetMap
diff --git a/app/pages/projekte/[slug].vue b/app/pages/projekte/[slug].vue index 2fdc6d2..2370ac6 100644 --- a/app/pages/projekte/[slug].vue +++ b/app/pages/projekte/[slug].vue @@ -54,18 +54,18 @@ usePageSeo( tech }}
- {{ $t('common.website') }} ↗{{ $t('common.website') }} ↗{{ $t('common.github') }} ↗{{ $t('common.source') }} ↗{{ $t('common.github') }} ↗{{ $t('common.source') }} ↗{{ $t('nav.mitmachen') }} → diff --git a/i18n/locales/da.json b/i18n/locales/da.json index 1c78b61..245a1d3 100644 --- a/i18n/locales/da.json +++ b/i18n/locales/da.json @@ -5,7 +5,8 @@ "language": "Vælg sprog", "openMenu": "Åbn menu", "closeMenu": "Luk menu", - "breadcrumbs": "Brødkrummenavigation" + "breadcrumbs": "Brødkrummenavigation", + "newTab": "Åbner i en ny fane." }, "hero": { "line1": "Åbne data.", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index ae44ad9..d12a280 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -5,7 +5,8 @@ "language": "Sprache wählen", "openMenu": "Menü öffnen", "closeMenu": "Menü schließen", - "breadcrumbs": "Brotkrumennavigation" + "breadcrumbs": "Brotkrumennavigation", + "newTab": "Öffnet in einem neuen Tab." }, "hero": { "line1": "Offene Daten.", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 7c5aa37..c727df7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -5,7 +5,8 @@ "language": "Choose language", "openMenu": "Open menu", "closeMenu": "Close menu", - "breadcrumbs": "Breadcrumbs" + "breadcrumbs": "Breadcrumbs", + "newTab": "Opens in a new tab." }, "hero": { "line1": "Open data.", diff --git a/package.json b/package.json index 25857a7..ff6ccde 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "@nuxt/eslint": "^1.9.0", "@playwright/test": "^1.55.0", "@tailwindcss/vite": "^4.1.0", + "@vitejs/plugin-vue": "^6.0.8", "eslint": "^10.10.0", + "parse5": "^8.0.1", "sharp": "0.35.4", "tailwindcss": "^4.1.0", "typescript": "^5.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38dbbe7..fcfcdba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,9 +144,15 @@ importers: '@tailwindcss/vite': specifier: ^4.1.0 version: 4.3.3(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.8(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) eslint: specifier: ^10.10.0 version: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) + parse5: + specifier: ^8.0.1 + version: 8.0.1 sharp: specifier: 0.35.4 version: 0.35.4(@types/node@26.5.0) diff --git a/shared/utils/links.ts b/shared/utils/links.ts new file mode 100644 index 0000000..1ec7431 --- /dev/null +++ b/shared/utils/links.ts @@ -0,0 +1,32 @@ +import { site } from '../config/site' + +function httpUrl(href: string | undefined, base: string) { + if (!href) return undefined + try { + const url = new URL(href, base) + return ['http:', 'https:'].includes(url.protocol) ? url : undefined + } catch { + return undefined + } +} + +export function isExternalUrl(href?: string, base: string = site.url) { + const url = httpUrl(href, base) + return !!url && url.origin !== new URL(base).origin +} + +// Only complete internal routes enter Markdown's translation lookup. Relative +// links and fragment-only links retain their current-document semantics. +export function internalLinkPath(href?: string) { + if (!href || !/^(?:\/|https?:)/i.test(href)) return undefined + const url = httpUrl(href, site.url) + if (!url || isExternalUrl(href)) return undefined + return url.pathname + url.search + url.hash +} + +export function linkAttributes(href?: string, rel?: string) { + const external = isExternalUrl(href) + const tokens = (rel ?? '').split(/\s+/).filter((token) => token && !['opener', 'noopener', 'noreferrer'].includes(token.toLowerCase())) + if (external) tokens.push('noopener', 'noreferrer') + return { target: external ? '_blank' : undefined, rel: [...new Set(tokens)].join(' ') || undefined } +} diff --git a/tests/e2e/links.spec.ts b/tests/e2e/links.spec.ts new file mode 100644 index 0000000..0843072 --- /dev/null +++ b/tests/e2e/links.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test' +import { editorialSlugs, locales, localizedPath, site } from '../../shared/config/site' +import de from '../../i18n/locales/de.json' with { type: 'json' } +import da from '../../i18n/locales/da.json' with { type: 'json' } +import en from '../../i18n/locales/en.json' with { type: 'json' } + +const messages = { de, da, en } + +// Inspect actual DOM URLs independently of the implementation helper, including +// anchors emitted by Content and every page/card/navigation renderer. +for (const locale of locales) { + test(`${locale}: external links are safe new-tab links and internal links stay same-tab`, async ({ page }) => { + let externalCount = 0 + let internalCount = 0 + const paths = ['/', '/projekte', '/projekte/open-city-planner', '/blog', ...editorialSlugs.map((slug) => `/${slug}`)] + for (const path of paths) { + await page.goto(localizedPath(path, locale)) + const links = await page.locator('a[href]').evaluateAll((nodes) => nodes.map((node) => ({ href: node.getAttribute('href')!, target: node.getAttribute('target'), rel: node.getAttribute('rel') }))) + for (const link of links) { + const url = new URL(link.href, site.url) + const external = ['http:', 'https:'].includes(url.protocol) && url.origin !== new URL(site.url).origin + if (external) { + externalCount++ + expect(link.target, link.href).toBe('_blank') + expect(link.rel?.split(/\s+/), link.href).toEqual(expect.arrayContaining(['noopener', 'noreferrer'])) + } else { + internalCount++ + expect(link.target, link.href).toBeNull() + } + } + } + expect(externalCount).toBeGreaterThan(20) + expect(internalCount).toBeGreaterThan(50) + await page.goto(localizedPath('/projekte/open-city-planner', locale)) + await expect(page.locator('.detail-aside a.button')).toHaveAttribute('target', '_blank') + await expect(page.locator('.detail-aside a[href^="https://github.com/"]').first()).toHaveAttribute('target', '_blank') + const source = page.locator('.detail-aside').getByRole('link', { name: messages[locale].common.source }) + await expect(source).toHaveAttribute('target', '_blank') + await expect(source).toHaveAttribute('rel', 'noopener noreferrer') + const profile = page.locator('footer').getByRole('link', { name: 'Mastodon', exact: true }) + await expect(profile).toHaveAttribute('href', site.social.mastodon) + await expect(profile).toHaveAttribute('target', '_blank') + await expect(profile).toHaveAttribute('rel', 'me noopener noreferrer') + await page.goto(localizedPath('/mitmachen', locale)) + const markdownInternal = page.locator(`.prose a[href="${localizedPath('/projekte', locale)}"]`) + await expect(markdownInternal).not.toHaveAttribute('target') + await markdownInternal.click() + await expect(page).toHaveURL(localizedPath('/projekte', locale)) + await page.goto(localizedPath('/blog', locale)) + await expect(page.locator('.blog-card h2 a').first()).not.toHaveAttribute('target') + await page.locator('.blog-card h2 a').first().click() + await expect(page.locator('h1')).toHaveCount(1) + await expect(page.locator('.breadcrumbs a').first()).not.toHaveAttribute('target') + }) +} diff --git a/tests/links.test.ts b/tests/links.test.ts new file mode 100644 index 0000000..504b464 --- /dev/null +++ b/tests/links.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, createSSRApp, defineComponent, h, ref } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5' +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import SiteLink from '../app/components/SiteLink.vue' +import ProseA from '../app/components/content/ProseA.vue' +import { internalLinkPath, isExternalUrl, linkAttributes } from '../shared/utils/links' +import { translationGroups } from '../shared/utils/translations' +import { site } from '../shared/config/site' + +beforeEach(() => { + vi.stubGlobal('computed', computed) + vi.stubGlobal('useI18n', () => ({ locale: ref('en') })) + vi.stubGlobal('useLocalePath', () => (path: string) => path) + vi.stubGlobal('useTranslations', () => ({ groups: ref(translationGroups({ + projects: [ + { locale: 'de', slug: 'karte', translationKey: 'map' }, + { locale: 'en', slug: 'map', translationKey: 'map' }, + { locale: 'de', slug: 'ohne-uebersetzung', translationKey: 'untranslated' }, + ], blog: [], pages: [], + })) })) +}) +afterEach(() => vi.unstubAllGlobals()) + +async function render(component: typeof SiteLink | typeof ProseA, props: Record) { + const app = createSSRApp({ render: () => h(component, props, () => 'Link label') }) + app.config.globalProperties.$t = () => 'Opens in a new tab.' + app.component('SiteLink', SiteLink) + app.component('NuxtLink', defineComponent({ + props: ['to'], setup: (props, { slots }) => () => h('a', { href: props.to, 'data-nuxt-link': 'true' }, slots.default?.()), + })) + const html = await renderToString(app) + const anchor = parseFragment(html).childNodes.find((node) => 'tagName' in node && node.tagName === 'a') + function textContent(node: DefaultTreeAdapterTypes.Node): string { + if ('value' in node) return node.value + return 'childNodes' in node ? node.childNodes.map(textContent).join('') : '' + } + return { text: anchor ? textContent(anchor) : '', attrs: Object.fromEntries(anchor && 'attrs' in anchor ? anchor.attrs.map(({ name, value }) => [name, value]) : []) } +} + +describe('one canonical origin rule', () => { + it.each(['/projekte', '/projekte?q=a#map', site.url + '/projekte?q=a#map', '//oklabflensburg.de/blog', '#section', 'relative', 'mailto:info@example.org', 'tel:+49123', '', undefined, 'http://[invalid'])('keeps %s in the same tab', (href) => { + expect(isExternalUrl(href)).toBe(false) + expect(linkAttributes(href).target).toBeUndefined() + }) + it.each(['https://github.com/oklabflensburg', 'https://norden.social/@oklabflensburg', 'https://www.openstreetmap.org/', '//example.org/path', 'http://oklabflensburg.de/', 'https://oklabflensburg.de:444/', 'https://oklabflensburg.de.example.org/'])('opens %s safely in a new tab', (href) => { + expect(isExternalUrl(href)).toBe(true) + expect(linkAttributes(href)).toEqual({ target: '_blank', rel: 'noopener noreferrer' }) + }) + it('uses origins rather than string prefixes and supports an explicit canonical base', () => { + expect(isExternalUrl('https://EXAMPLE.org:443/a', 'https://example.org')).toBe(false) + expect(internalLinkPath(site.url + '/projekte?q=a%20b#map')).toBe('/projekte?q=a%20b#map') + expect(internalLinkPath('//github.com/oklabflensburg')).toBeUndefined() + }) +}) + +describe('rendered link components', () => { + it('renders Markdown external anchors with safe attributes and intact query/fragment', async () => { + const href = 'https://github.com/oklabflensburg?q=a%20b#readme' + const { attrs, text } = await render(ProseA, { href, target: '_self', rel: 'opener' }) + expect(attrs).toMatchObject({ href, target: '_blank', rel: 'noopener noreferrer', 'aria-description': 'Opens in a new tab.' }) + expect(attrs['data-nuxt-link']).toBeUndefined() + expect(text).toBe('Link label') + }) + it.each(['/projekte/karte?q=a%20b#map', site.url + '/projekte/karte?q=a%20b#map'])('localizes %s with NuxtLink, query and fragment preserved', async (href) => { + const { attrs } = await render(ProseA, { href, target: '_blank' }) + expect(attrs).toMatchObject({ href: '/en/projects/map?q=a%20b#map', 'data-nuxt-link': 'true' }) + expect(attrs.target).toBeUndefined() + }) + it('keeps absent translations and document fragments real', async () => { + expect((await render(ProseA, { href: '/projekte/ohne-uebersetzung?q=a#b' })).attrs.href).toBe('/projekte/ohne-uebersetzung?q=a#b') + expect((await render(ProseA, { href: '#heading' })).attrs).toEqual({ href: '#heading' }) + }) + it.each(['/projekte/karte?q=a%20b#map', site.url + '/projekte/karte?q=a%20b#map'])('preserves semantic rel values on internal Markdown link %s', async (href) => { + const rel = 'author bookmark nofollow' + const { attrs } = await render(ProseA, { href, rel, target: '_blank' }) + expect(attrs).toMatchObject({ href: '/en/projects/map?q=a%20b#map', rel, 'data-nuxt-link': 'true' }) + expect(attrs.target).toBeUndefined() + }) + it('preserves identity verification and accessible names while preventing overrides', async () => { + const { attrs } = await render(SiteLink, { href: site.social.mastodon, rel: 'me opener', target: '_self', 'aria-label': 'Mastodon' }) + expect(attrs).toMatchObject({ target: '_blank', rel: 'me noopener noreferrer', 'aria-label': 'Mastodon' }) + expect((await render(SiteLink, { href: site.url + '/blog', target: '_blank' })).attrs.target).toBeUndefined() + }) +}) + +it('all Vue anchors use SiteLink so future links inherit the rule', () => { + function files(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(join(dir, entry.name)) : [join(dir, entry.name)]) + } + for (const path of files('app').filter((path) => path.endsWith('.vue') && !path.endsWith('/SiteLink.vue'))) { + expect(readFileSync(path, 'utf8'), path).not.toMatch(/)/) + } +}) diff --git a/vitest.config.ts b/vitest.config.ts index 9eef0e8..e0cd75b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,10 @@ import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath } from 'node:url' export default defineConfig({ + plugins: [vue()], + resolve: { alias: { '#shared': fileURLToPath(new URL('./shared', import.meta.url)) } }, test: { environment: 'node', include: ['tests/**/*.test.ts'],