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()
{{ event.description }}
{{ event.location }} · {{ event.address }}
{{ $t(`events.${event.status}`) }}
- {{ $t('events.details') }} ↗{{ $t('events.details') }} ↗{{ $t('events.register') }} ↗{{ $t('events.register') }} ↗
diff --git a/app/components/MeetingSection.vue b/app/components/MeetingSection.vue
index 107776f..2b56f5d 100644
--- a/app/components/MeetingSection.vue
+++ b/app/components/MeetingSection.vue
@@ -22,7 +22,7 @@ const localePath = useLocalePath()
diff --git a/app/components/SiteLink.vue b/app/components/SiteLink.vue
new file mode 100644
index 0000000..223dc39
--- /dev/null
+++ b/app/components/SiteLink.vue
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/components/TeamList.vue b/app/components/TeamList.vue
index 42323a7..1479307 100644
--- a/app/components/TeamList.vue
+++ b/app/components/TeamList.vue
@@ -18,14 +18,14 @@ const { data: members } = await useAsyncData('team', () =>
{{ member.name }}
{{ member.role[locale] }}
{{ member.bio[locale] }}
- GitHub ↗GitHub ↗{{ link.label }} ↗{{ link.label }} ↗
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' })
{{ error.statusCode }}
- {{ $t('error.home') }}{{ $t('error.home') }}
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'],