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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 4 additions & 4 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ const localePath = useLocalePath()
<p class="footer-tagline">{{ $t('footer.tagline') }}</p>
<div class="footer-links">
<div class="footer-social">
<a :href="site.github" class="icon-button" aria-label="GitHub"><AppIcon name="github" :size="18" /></a>
<a :href="site.social.mastodon" class="icon-button" aria-label="Mastodon" rel="me"><AppIcon name="social" :size="18" /></a>
<a :href="`mailto:${site.contact.email}`" class="icon-button" :aria-label="$t('common.contact')"><AppIcon name="mail" :size="18" /></a>
<a :href="`/rss.xml?lang=${$i18n.locale}`" class="icon-button" aria-label="RSS"><AppIcon name="rss" :size="18" /></a>
<SiteLink :href="site.github" class="icon-button" aria-label="GitHub"><AppIcon name="github" :size="18" /></SiteLink>
<SiteLink :href="site.social.mastodon" class="icon-button" aria-label="Mastodon" rel="me"><AppIcon name="social" :size="18" /></SiteLink>
<SiteLink :href="`mailto:${site.contact.email}`" class="icon-button" :aria-label="$t('common.contact')"><AppIcon name="mail" :size="18" /></SiteLink>
<SiteLink :href="`/rss.xml?lang=${$i18n.locale}`" class="icon-button" aria-label="RSS"><AppIcon name="rss" :size="18" /></SiteLink>
</div>
<nav :aria-label="$t('footer.group0')">
<NuxtLink v-for="item in ['mitmachen', 'veranstaltungen', 'team', 'daten-sind-daten']" :key="item" :to="localePath(`/${item}`)">{{ $t(`nav.${item}`) }}</NuxtLink>
Expand Down
2 changes: 1 addition & 1 deletion app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function escapeMenu() {
}
</script>
<template>
<a class="skip-link" href="#main-content">{{ $t('a11y.skip') }}</a>
<SiteLink class="skip-link" href="#main-content">{{ $t('a11y.skip') }}</SiteLink>
<header class="header" @keydown.esc="escapeMenu">
<div class="shell header-inner">
<NuxtLink :to="localePath('/')" class="brand"><AppLogo /></NuxtLink>
Expand Down
10 changes: 5 additions & 5 deletions app/components/EditorialPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ usePageSeo(
{{ slug === 'ueber-uns' ? site.network.name : site.association.name }}
</h2>
<p>{{ $t(slug === 'ueber-uns' ? 'home.networkText' : 'home.associationText') }}</p>
<a v-if="slug === 'ueber-uns'" :href="site.network.url" class="text-link"
>{{ site.network.name }} ↗</a
<SiteLink v-if="slug === 'ueber-uns'" :href="site.network.url" class="text-link"
>{{ site.network.name }} ↗</SiteLink
>
</aside>
</div>
Expand All @@ -56,9 +56,9 @@ usePageSeo(
class="contact-panel"
>
<h2>{{ $t('common.contact') }}</h2>
<a :href="`mailto:${site.contact.email}`" class="text-link">{{ site.contact.email }} ↗</a
><a v-if="slug === 'kontakt'" :href="site.github" class="text-link"
>GitHub ↗</a
<SiteLink :href="`mailto:${site.contact.email}`" class="text-link">{{ site.contact.email }} ↗</SiteLink
><SiteLink v-if="slug === 'kontakt'" :href="site.github" class="text-link"
>GitHub ↗</SiteLink
>
</div>
<MeetingSection
Expand Down
6 changes: 3 additions & 3 deletions app/components/EventCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ const format = (value: string) =>
<p>{{ event.description }}</p>
<p>{{ event.location }} · {{ event.address }}</p>
<p>{{ $t(`events.${event.status}`) }}</p>
<a :href="event.url" class="text-link">{{ $t('events.details') }} ↗</a
><a
<SiteLink :href="event.url" class="text-link">{{ $t('events.details') }} ↗</SiteLink
><SiteLink
v-if="event.registrationUrl && event.status === 'scheduled'"
:href="event.registrationUrl"
class="button"
>{{ $t('events.register') }} ↗</a
>{{ $t('events.register') }} ↗</SiteLink
>
</article>
</template>
2 changes: 1 addition & 1 deletion app/components/MeetingSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const localePath = useLocalePath()
<div class="meeting-note">
<p>{{ $t('meeting.welcome') }}</p>
<p>{{ $t('meeting.note') }}</p>
<a :href="site.meeting.map" class="text-link">{{ $t('meeting.map') }} <AppIcon name="arrow" :size="16" /></a>
<SiteLink :href="site.meeting.map" class="text-link">{{ $t('meeting.map') }} <AppIcon name="arrow" :size="16" /></SiteLink>
</div>
</section>
</template>
12 changes: 12 additions & 0 deletions app/components/SiteLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { isExternalUrl, linkAttributes } from '#shared/utils/links'
// Consume target so callers cannot override the site-wide same/new-tab rule.
const props = defineProps<{ href?: string; rel?: string; target?: string }>()
</script>
<template>
<a
:href="href"
v-bind="linkAttributes(href, rel)"
:aria-description="isExternalUrl(props.href) ? $t('a11y.newTab') : undefined"
><slot /></a>
</template>
8 changes: 4 additions & 4 deletions app/components/TeamList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ const { data: members } = await useAsyncData('team', () =>
<h2>{{ member.name }}</h2>
<p class="eyebrow">{{ member.role[locale] }}</p>
<p>{{ member.bio[locale] }}</p>
<a v-if="member.github" :href="member.github" class="text-link"
>GitHub ↗</a
><a
<SiteLink v-if="member.github" :href="member.github" class="text-link"
>GitHub ↗</SiteLink
><SiteLink
v-for="link in member.links"
:key="link.url"
:href="link.url"
class="text-link"
>{{ link.label }} ↗</a
>{{ link.label }} ↗</SiteLink
>
</article>
</div>
Expand Down
4 changes: 2 additions & 2 deletions app/components/content/LegalDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ const details = computed(() => optionalLegalFields.filter((field) =>
<dl v-if="kind !== 'hosting'" class="mt-6 grid gap-4">
<div v-if="contact.email" data-legal-field="email">
<dt class="font-bold text-text">{{ $t('legal.email') }}</dt>
<dd><a v-if="links.email" :href="links.email">{{ contact.email }}</a><span v-else>{{ contact.email }}</span></dd>
<dd><SiteLink v-if="links.email" :href="links.email">{{ contact.email }}</SiteLink><span v-else>{{ contact.email }}</span></dd>
</div>
<div v-if="contact.phone" data-legal-field="phone">
<dt class="font-bold text-text">{{ $t('legal.phone') }}</dt>
<dd><a v-if="links.phone" :href="links.phone">{{ contact.phone }}</a><span v-else>{{ contact.phone }}</span></dd>
<dd><SiteLink v-if="links.phone" :href="links.phone">{{ contact.phone }}</SiteLink><span v-else>{{ contact.phone }}</span></dd>
</div>
<div v-for="field in details" :key="field" :data-legal-field="field">
<dt class="font-bold text-text">{{ $t(`legal.${field}`) }}</dt>
Expand Down
15 changes: 9 additions & 6 deletions app/components/content/ProseA.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
<script setup lang="ts">
import { findTranslationGroup } from '#shared/utils/translations'
const props = defineProps<{ href?: string }>()
import { internalLinkPath } from '#shared/utils/links'
const props = defineProps<{ href?: string; target?: string; rel?: string }>()
const localePath = useLocalePath()
const { locale } = useI18n()
const { groups } = useTranslations()
const internalPath = computed(() => internalLinkPath(props.href))
const target = computed(() => {
if (!props.href?.startsWith('/') || props.href.startsWith('//')) return props.href
const url = new URL(props.href, 'https://local.invalid')
if (!internalPath.value) return props.href
const url = new URL(internalPath.value, 'https://local.invalid')
const group = findTranslationGroup(groups.value ?? [], url.pathname)
const variant = group?.variants.find((item) => item.locale === locale.value)
// An absent translation keeps the real source-language link, never a guessed URL.
return variant ? localePath(variant.path, variant.locale) + url.search + url.hash : props.href
return variant ? localePath(variant.path, variant.locale) + url.search + url.hash : internalPath.value
})
</script>
<template>
<NuxtLink
v-if="href?.startsWith('/') && !href.startsWith('//')"
v-if="internalPath"
:to="target"
:rel="rel"
><slot /></NuxtLink
><a v-else :href="href"><slot /></a>
><SiteLink v-else :href="href" :rel="rel"><slot /></SiteLink>
</template>
4 changes: 2 additions & 2 deletions app/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ useSeoMeta({ title: () => t('error.title'), robots: 'noindex, follow' })
<template>
<main class="shell page">
<p class="eyebrow">{{ error.statusCode }}</p>
<PageHeading :title="$t('error.title')" :description="$t('error.text')" /><a
<PageHeading :title="$t('error.title')" :description="$t('error.text')" /><SiteLink
:href="localePath('/')"
class="button"
>{{ $t('error.home') }}</a
>{{ $t('error.home') }}</SiteLink
>
</main>
</template>
2 changes: 1 addition & 1 deletion app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ usePageSeo(() => `${t('hero.line1')} ${t('hero.line2')}`, () => t('hero.intro'))
<img src="/images/flensburg-map.svg" :alt="$t('hero.map')" width="800" height="680" fetchpriority="high" />
<div class="hero-map-fade" /><span class="absolute top-[74%] left-[47%] text-base font-bold text-brand">{{ site.meeting.city }}</span>
<p class="map-caption">{{ $t('hero.caption') }}</p>
<a class="map-credit" href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a>
<SiteLink class="map-credit" href="https://www.openstreetmap.org/copyright">© OpenStreetMap</SiteLink>
</div>
</section>
<section class="pillars" :aria-label="$t('nav.ueber-uns')">
Expand Down
12 changes: 6 additions & 6 deletions app/pages/projekte/[slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,18 @@ usePageSeo(
tech
}}</span>
</div>
<a
<SiteLink
v-if="project.links.website"
:href="project.links.website"
class="button"
>{{ $t('common.website') }} ↗</a
><a
>{{ $t('common.website') }} ↗</SiteLink
><SiteLink
v-if="project.links.github"
:href="project.links.github"
class="text-link"
>{{ $t('common.github') }} ↗</a
><a :href="project.source" class="text-link"
>{{ $t('common.source') }} ↗</a
>{{ $t('common.github') }} ↗</SiteLink
><SiteLink :href="project.source" class="text-link"
>{{ $t('common.source') }} ↗</SiteLink
><NuxtLink :to="localePath('/mitmachen')" class="text-link"
>{{ $t('nav.mitmachen') }} →</NuxtLink
>
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions shared/utils/links.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
55 changes: 55 additions & 0 deletions tests/e2e/links.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
}
Loading