@brumaombra/ui-vintage 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -33,6 +33,7 @@ Only build something new when no entry point fits, and compose it from library p
33
33
  - Icons are Hugeicons definitions: import each one from its own module (`import Rocket01Icon from '@hugeicons/core-free-icons/Rocket01Icon'`, never the `@hugeicons/core-free-icons` barrel, which makes the build parse the whole icon set) and pass them to `:icon` props, or render them with `HugeiconsIcon` from `@hugeicons/vue`. Both packages are peer dependencies, so the app must list them in its own `package.json`.
34
34
  - Form controls use `v-model`. Mark invalid fields with `aria-invalid` and show messages with `FieldError`.
35
35
  - Library strings are translated through vue-i18n (`uiVintage.*` keys). App copy belongs in the app's own locale files.
36
+ - Pass links as plain paths (`to: '/settings'`, `app-link-to="/"`), never `localePath()` results: the components render `NuxtLinkLocale`, which adds the language prefix and navigates client-side. External URLs work in the same props. For your own link buttons, use `<Button as-child>` around a `NuxtLinkLocale`.
36
37
 
37
38
  ## 4. Check your work
38
39
 
package/COMPONENTS.md CHANGED
@@ -255,7 +255,7 @@ import { LoadMoreButton } from '@brumaombra/ui-vintage/load-more-button';
255
255
 
256
256
  ### `@brumaombra/ui-vintage/text-link`
257
257
 
258
- Inline text link that renders an anchor or a router link component (pass `NuxtLink` as `link-component`).
258
+ Inline text link for an internal path (localized with `NuxtLinkLocale`) or an external URL.
259
259
 
260
260
  ```ts
261
261
  import { TextLink } from '@brumaombra/ui-vintage/text-link';
@@ -267,7 +267,6 @@ import { TextLink } from '@brumaombra/ui-vintage/text-link';
267
267
  | --- | --- | --- |
268
268
  | `to` *(required)* | `string` | |
269
269
  | `text` *(required)* | `string` | |
270
- | `linkComponent` | `Component \| string` | `'a'` |
271
270
 
272
271
  - **Emits:** `click` (event: MouseEvent).
273
272
 
@@ -3057,14 +3056,14 @@ import { BackgroundGrid } from '@brumaombra/ui-vintage/background-grid';
3057
3056
 
3058
3057
  ### `@brumaombra/ui-vintage/dashboard-shell`
3059
3058
 
3060
- Complete dashboard layout: collapsible sidebar built from `sidebar-sections`, mobile sheet, sticky topbar, and grid background. Pass `NuxtLink` as `sidebar-link-component` for client-side navigation.
3059
+ Complete dashboard layout: collapsible sidebar built from `sidebar-sections`, mobile sheet, sticky topbar, and grid background. Item `to` paths are localized and navigate client-side (`NuxtLinkLocale`).
3061
3060
 
3062
3061
  ```ts
3063
3062
  import { DashboardShell } from '@brumaombra/ui-vintage/dashboard-shell';
3064
3063
  ```
3065
3064
 
3066
3065
  ```vue
3067
- <DashboardShell title="Projects" :sidebar-sections="sections" :sidebar-link-component="NuxtLink">
3066
+ <DashboardShell title="Projects" :sidebar-sections="sections">
3068
3067
  <template #topbar-trailing>
3069
3068
  <ThemeSelector />
3070
3069
  </template>
@@ -3084,8 +3083,7 @@ import { DashboardShell } from '@brumaombra/ui-vintage/dashboard-shell';
3084
3083
  | `description` | `string` | `''` |
3085
3084
  | `collapsible` | `'offcanvas' \| 'icon' \| 'none'` | `'offcanvas'` |
3086
3085
  | `compact` | `boolean` | `false` |
3087
- | `sidebarSections` | `Array<{ id: string; label?: string; items: Array<{ id: string; label: string; description?: string; icon?: HugeiconsIconDefinition \| null; href?: string; to?: unknown; active?: boolean; newTab?: boolean }> }>` | `[]` |
3088
- | `sidebarLinkComponent` | `Component \| string` | `'a'` |
3086
+ | `sidebarSections` | `Array<{ id: string; label?: string; items: Array<{ id: string; label: string; description?: string; icon?: HugeiconsIconDefinition \| null; to: string; active?: boolean; newTab?: boolean }> }>` | `[]` |
3089
3087
  | `showBackground` | `boolean` | `true` |
3090
3088
  | `insetClass` | `HTMLAttributes['class']` | |
3091
3089
  | `topbarClass` | `HTMLAttributes['class']` | |
@@ -3178,7 +3176,6 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3178
3176
  | `logoClass` | `HTMLAttributes['class']` | |
3179
3177
  | `appDescription` | `string` | `''` |
3180
3178
  | `sections` | `LandingFooterSection[]` | `[]` |
3181
- | `linkComponent` | `Component \| string` | `'a'` |
3182
3179
  | `authorName` | `string` | `''` |
3183
3180
  | `authorLink` | `string` | `''` |
3184
3181
  | `year` | `number` | `new Date().getFullYear()` |
@@ -3196,8 +3193,7 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3196
3193
  interface LandingFooterLink {
3197
3194
  id: string;
3198
3195
  label: string;
3199
- href?: string;
3200
- to?: unknown;
3196
+ to: string;
3201
3197
  newTab?: boolean;
3202
3198
  }
3203
3199
  interface LandingFooterSection {
@@ -3244,7 +3240,6 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3244
3240
  | `logoClass` | `HTMLAttributes['class']` | |
3245
3241
  | `appDescription` | `string` | `''` |
3246
3242
  | `sections` | `LandingFooterSection[]` | `[]` |
3247
- | `linkComponent` | `Component \| string` | `'a'` |
3248
3243
  | `authorName` | `string` | `''` |
3249
3244
  | `authorLink` | `string` | `''` |
3250
3245
  | `year` | `number` | `new Date().getFullYear()` |
@@ -3262,8 +3257,7 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3262
3257
  interface LandingFooterLink {
3263
3258
  id: string;
3264
3259
  label: string;
3265
- href?: string;
3266
- to?: unknown;
3260
+ to: string;
3267
3261
  newTab?: boolean;
3268
3262
  }
3269
3263
  interface LandingFooterSection {
@@ -3684,7 +3678,7 @@ import type { BlogPostData, BlogCategoryData, BlogTagData } from '@brumaombra/ui
3684
3678
  ```ts
3685
3679
  function slugify(value: unknown)
3686
3680
  function normalizeContentPath(path: string)
3687
- function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[]
3681
+ function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[]
3688
3682
  function buildTagsFromPosts(posts: BlogPostData[]): BlogTagData[]
3689
3683
  function sortByStableSeed(items: T[], seed: string, getKey: (item: T) => string)
3690
3684
  interface BlogPostData {
package/README.md CHANGED
@@ -140,7 +140,7 @@ if (confirmed) {
140
140
 
141
141
  ### Build a layout
142
142
 
143
- Shells own the layout and responsive behavior; your app owns the navigation data and the content:
143
+ Shells own the layout and responsive behavior; your app owns the navigation data and the content. Links take plain paths (`to: '/settings'`): the components render `NuxtLinkLocale`, which adds the current language prefix and navigates client-side, and leaves external URLs as they are:
144
144
 
145
145
  ```vue
146
146
  <script setup>
@@ -151,8 +151,8 @@ const sidebarSections = [{
151
151
  id: 'workspace',
152
152
  label: 'Workspace',
153
153
  items: [
154
- { id: 'overview', label: 'Overview', href: '/', active: true },
155
- { id: 'settings', label: 'Settings', href: '/settings' }
154
+ { id: 'overview', label: 'Overview', to: '/', active: true },
155
+ { id: 'settings', label: 'Settings', to: '/settings' }
156
156
  ]
157
157
  }];
158
158
  </script>
@@ -402,7 +402,7 @@ New entry points need an export in `package.json` and a description and category
402
402
  - Nuxt 4 and Vue 3.5 or newer.
403
403
  - Tailwind CSS v4 with `@tailwindcss/vite` registered in `vite.plugins`.
404
404
  - `@hugeicons/core-free-icons` 4.3 or newer and `@hugeicons/vue` 1 (the icons you pass to `:icon` props, and the ones the components render). Import each icon from its own module, as in the example above: the package barrel makes the build parse the whole icon set.
405
- - `vue-i18n` 9, 10, or 11 with an i18n setup, such as `@nuxtjs/i18n` (the components call `useI18n()`).
405
+ - `@nuxtjs/i18n` 10 (the components call `useI18n()`, and every link renders `NuxtLinkLocale`).
406
406
  - `@nuxt/image` 2 for image components (the module installs it when needed).
407
407
  - `@nuxt/content` 3, only for the `blog` and `content` components.
408
408
 
@@ -411,7 +411,7 @@ New entry points need an export in `package.json` and a description and category
411
411
  - **Components can't resolve Nuxt imports:** check that the app runs Nuxt 4 and that `@brumaombra/ui-vintage` is registered in `nuxt.config`.
412
412
  - **Styles are missing:** register the module once, add `tailwindcss()` from `@tailwindcss/vite` to `vite.plugins`, and restart the dev server after changing `nuxt.config`.
413
413
  - **`NuxtImg` is unavailable:** install `@nuxt/image`, or let the module install it during setup.
414
- - **`useI18n` errors or missing translations:** register `@nuxtjs/i18n` (or install Vue I18n yourself); the components need an i18n composer, and the library messages are merged into it.
414
+ - **`useI18n` errors, missing translations, or `NuxtLinkLocale` failing to resolve:** register `@nuxtjs/i18n`; the components need its composer and link component, and the library messages are merged into it.
415
415
  - **An import fails:** use the public subpath from `package.json`, such as `@brumaombra/ui-vintage/button`, never an internal `src/` path.
416
416
 
417
417
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brumaombra/ui-vintage",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
5
5
  "private": false,
6
6
  "author": "Mauro Brambilla",
@@ -81,7 +81,7 @@ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`
81
81
  <template>
82
82
  <header :class="cn('relative flex flex-col gap-6 pt-6 md:pt-12', alignClass.header)">
83
83
  <!-- Announcement (links to something new, such as the latest post) -->
84
- <NuxtLink v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
84
+ <NuxtLinkLocale v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
85
85
  <span class="inline-flex max-w-full items-center gap-2 rounded border border-warning/35 bg-warning/10 px-3 py-1 text-xs text-foreground transition-colors duration-150 group-hover/announce:border-warning/70">
86
86
  <span aria-hidden="true" class="relative flex size-1.5 shrink-0 text-warning">
87
87
  <span class="absolute inline-flex size-full animate-uv-ping-soft rounded-full bg-current opacity-60" />
@@ -91,7 +91,7 @@ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`
91
91
  <span class="truncate opacity-80">{{ props.announcement.text }}</span>
92
92
  <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 shrink-0 transition-transform duration-300 ease-spring group-hover/announce:translate-x-0.5" />
93
93
  </span>
94
- </NuxtLink>
94
+ </NuxtLinkLocale>
95
95
 
96
96
  <!-- Badges -->
97
97
  <div v-if="props.badges.length > 0" :class="cn('flex flex-wrap gap-2', alignClass.row)">
@@ -13,14 +13,14 @@ import BlogViewAllLink from './BlogViewAllLink.vue';
13
13
  import CategoriesSection from './CategoriesSection.vue';
14
14
  import HeaderCarousel from './HeaderCarousel.vue';
15
15
 
16
- // Post shown in the carousel and in the list (paths already localized)
16
+ // Post shown in the carousel and in the list (the links localize the paths)
17
17
  type BlogListPost = {
18
18
  path: string;
19
19
  title: string;
20
20
  description?: string;
21
21
  image?: string;
22
22
  categoryText?: string;
23
- categoryPath?: string;
23
+ categorySlug?: string;
24
24
  };
25
25
 
26
26
  // Props
@@ -57,6 +57,12 @@ const emit = defineEmits<{
57
57
 
58
58
  const { t } = useI18n();
59
59
 
60
+ // Featured posts with the link to their category page
61
+ const carouselPosts = computed(() => props.featuredPosts.map(post => ({
62
+ ...post,
63
+ categoryPath: post.categorySlug ? `/blog/categories/${post.categorySlug}` : undefined
64
+ })));
65
+
60
66
  // Announce the latest post in the header
61
67
  const headerAnnouncement = computed(() => props.featuredPosts[0] ? { text: props.featuredPosts[0].title, to: props.featuredPosts[0].path } : null);
62
68
 
@@ -80,7 +86,7 @@ const headerStats = computed(() => [
80
86
  <!-- Header carousel -->
81
87
  <section>
82
88
  <BlogSectionTitle :title="t('uiVintage.blog.latestPosts')" :description="t('uiVintage.blog.latestPostsDescription')" />
83
- <HeaderCarousel :featured-posts="props.featuredPosts" />
89
+ <HeaderCarousel :featured-posts="carouselPosts" />
84
90
  </section>
85
91
 
86
92
  <!-- Categories section -->
@@ -38,17 +38,17 @@ const showModifiedDate = computed(() => Boolean(props.dateModified) && formatted
38
38
  <!-- Author -->
39
39
  <div :class="['flex gap-3 px-5 py-4', props.authorBio ? 'items-start' : 'items-center']">
40
40
  <!-- Author image -->
41
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
41
+ <NuxtLinkLocale :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
42
42
  <NuxtImg :src="props.authorImageUrl" :alt="props.author" height="44" width="44" format="avif" quality="40" :sizes="{ 480: '44px', 1280: '44px' }" loading="lazy" decoding="async" class="size-11 rounded border border-border object-cover" />
43
- </NuxtLink>
43
+ </NuxtLinkLocale>
44
44
 
45
45
  <!-- Author name and bio -->
46
46
  <div class="min-w-0 flex-1">
47
47
  <p class="text-[11px] font-semibold tracking-wider text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
48
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
48
+ <NuxtLinkLocale :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
49
49
  {{ props.author }}
50
50
  <HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 group-hover/link:text-primary" />
51
- </NuxtLink>
51
+ </NuxtLinkLocale>
52
52
  <p v-if="props.authorBio" class="mt-1.5 text-xs leading-relaxed text-muted-foreground md:text-sm">
53
53
  {{ props.authorBio }}
54
54
  </p>
@@ -7,7 +7,7 @@ import BlogHeaderSection from './BlogHeaderSection.vue';
7
7
  import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
8
8
  import PostsList from './PostsList.vue';
9
9
 
10
- // Props (the posts of a category or a tag, their paths already localized)
10
+ // Props (the posts of a category or a tag, the links localize their paths)
11
11
  const props = withDefaults(defineProps<{
12
12
  section: 'categories' | 'tags';
13
13
  title: string;
@@ -18,8 +18,8 @@ const props = withDefaults(defineProps<{
18
18
  <template>
19
19
  <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:grid-cols-4">
20
20
  <!-- Category card -->
21
- <NuxtLink v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
21
+ <NuxtLinkLocale v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
22
22
  <CategoryCard :name="category.name" :image="category.image" :count="category.count" />
23
- </NuxtLink>
23
+ </NuxtLinkLocale>
24
24
  </div>
25
25
  </template>
@@ -112,10 +112,10 @@ onUnmounted(() => {
112
112
  <div class="relative h-[28rem] w-full md:h-[32rem]">
113
113
  <div v-for="(post, index) in props.featuredPosts" :id="`${slideIdPrefix}-${index}`" :key="post.path || post.title || index" role="group" aria-roledescription="slide" :aria-label="`${index + 1} / ${slideCount}`" :aria-hidden="currentSlide !== index" :inert="currentSlide !== index || undefined" :class="cn('absolute inset-0 transition-[opacity,scale] duration-700 ease-out-expo', currentSlide === index ? 'z-10 scale-100 opacity-100' : 'z-0 scale-[1.02] opacity-0')">
114
114
  <!-- Slide image with a slow zoom -->
115
- <NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
115
+ <NuxtLinkLocale :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
116
116
  <NuxtImg v-if="post.image" :src="post.image" :alt="post.title" width="1200" height="640" format="avif" quality="45" :sizes="{ 480: '480px', 1536: '1152px' }" :loading="index === 0 ? 'eager' : 'lazy'" :fetchpriority="index === 0 ? 'high' : 'auto'" :preload="index === 0" :class="cn('size-full object-cover', currentSlide === index && 'animate-uv-ken-burns')" />
117
117
  <div v-else class="size-full bg-[radial-gradient(circle_at_20%_20%,color-mix(in_oklab,var(--primary)_45%,transparent),transparent_60%)] bg-surface" />
118
- </NuxtLink>
118
+ </NuxtLinkLocale>
119
119
 
120
120
  <!-- Legibility shade -->
121
121
  <div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/90 via-black/45 to-black/5" />
@@ -124,16 +124,16 @@ onUnmounted(() => {
124
124
  <!-- Slide content (replays its entrance every time the slide becomes active) -->
125
125
  <div class="absolute inset-x-0 bottom-0 flex flex-col gap-4 p-6 pb-20 text-white md:max-w-3xl md:p-10 md:pb-24">
126
126
  <!-- Category badge -->
127
- <NuxtLink v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
127
+ <NuxtLinkLocale v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
128
128
  <Badge :text="post.categoryText" pulse class="border-white/25 bg-white/15 text-white backdrop-blur-md transition-colors hover:bg-white/25" />
129
- </NuxtLink>
129
+ </NuxtLinkLocale>
130
130
 
131
131
  <!-- Title -->
132
- <NuxtLink :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
132
+ <NuxtLinkLocale :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
133
133
  <h3 class="line-clamp-3 text-2xl leading-tight font-bold tracking-tight text-balance drop-shadow-sm md:text-5xl">
134
134
  {{ post.title }}
135
135
  </h3>
136
- </NuxtLink>
136
+ </NuxtLinkLocale>
137
137
 
138
138
  <!-- Description -->
139
139
  <p v-if="post.description" :class="cn('line-clamp-2 max-w-2xl text-xs leading-relaxed text-white/80 md:text-base', currentSlide === index && 'animate-uv-fade-up [animation-delay:180ms]')">
@@ -143,10 +143,10 @@ onUnmounted(() => {
143
143
  <!-- Read post button -->
144
144
  <div :class="cn(currentSlide === index && 'animate-uv-fade-up [animation-delay:260ms]')">
145
145
  <Button as-child size="lg" class="group/cta">
146
- <NuxtLink :to="post.path">
146
+ <NuxtLinkLocale :to="post.path">
147
147
  {{ t('uiVintage.blog.goToPost') }}
148
148
  <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/cta:translate-x-1" />
149
- </NuxtLink>
149
+ </NuxtLinkLocale>
150
150
  </Button>
151
151
  </div>
152
152
  </div>
@@ -19,8 +19,8 @@ const props = withDefaults(defineProps<{
19
19
  <template>
20
20
  <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
21
21
  <!-- Post cards rise in row by row as they scroll into view -->
22
- <NuxtLink v-for="(post, index) in props.posts" :key="post.path" data-aos="blur-up" :data-aos-delay="(index % 3) * 90" :to="post.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
22
+ <NuxtLinkLocale v-for="(post, index) in props.posts" :key="post.path" data-aos="blur-up" :data-aos-delay="(index % 3) * 90" :to="post.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
23
23
  <PostCard class="h-full" :image="post.image" :category="post.categoryText" :title="post.title" :description="post.description" />
24
- </NuxtLink>
24
+ </NuxtLinkLocale>
25
25
  </div>
26
26
  </template>
@@ -38,8 +38,8 @@ export const slugify = (value: unknown) => {
38
38
  // Remove the trailing slashes of a route path, since content paths never have one ("/blog/post/" -> "/blog/post")
39
39
  export const normalizeContentPath = (path: string) => path.replace(/(.)\/+$/, '$1');
40
40
 
41
- // Build the unique categories of the posts, with their post count and the image of their first post
42
- export const buildCategoriesFromPosts = (posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[] => {
41
+ // Build the unique categories of the posts, with their post count and the image of their first post (the path is the blog category page by default)
42
+ export const buildCategoriesFromPosts = (posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[] => {
43
43
  const categoriesList = posts.map(post => post.categorySlug);
44
44
  const uniqueCategories = categoriesList.filter((category, index, self): category is string => !!category && self.indexOf(category) === index);
45
45
 
@@ -2,7 +2,7 @@
2
2
  import { NuxtImg } from '#components';
3
3
  import { computed } from 'vue';
4
4
  import { useI18n } from 'vue-i18n';
5
- import type { Component, HTMLAttributes } from 'vue';
5
+ import type { HTMLAttributes } from 'vue';
6
6
  import { HugeiconsIcon } from '@hugeicons/vue';
7
7
  import BackgroundGrid from '../background-grid/BackgroundGrid.vue';
8
8
  import { ThemeSelector } from '../theme-selector';
@@ -11,7 +11,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupConte
11
11
  import { cn } from '../../lib/utils';
12
12
  import type { HugeiconsIconDefinition } from '../../lib/common-types';
13
13
 
14
- // Props
14
+ // Props (item and home paths are localized by the links, external URLs work too)
15
15
  const props = withDefaults(defineProps<{
16
16
  appName?: string;
17
17
  appLogo?: string;
@@ -29,13 +29,11 @@ const props = withDefaults(defineProps<{
29
29
  label: string;
30
30
  description?: string;
31
31
  icon?: HugeiconsIconDefinition | null;
32
- href?: string;
33
- to?: unknown;
32
+ to: string;
34
33
  active?: boolean;
35
34
  newTab?: boolean;
36
35
  }>;
37
36
  }>;
38
- sidebarLinkComponent?: Component | string;
39
37
  showBackground?: boolean;
40
38
  insetClass?: HTMLAttributes['class'];
41
39
  topbarClass?: HTMLAttributes['class'];
@@ -50,26 +48,14 @@ const props = withDefaults(defineProps<{
50
48
  collapsible: 'offcanvas',
51
49
  compact: false,
52
50
  sidebarSections: () => [],
53
- sidebarLinkComponent: 'a',
54
51
  showBackground: true
55
52
  });
56
53
 
57
- // Resolve link props for sidebar items
54
+ // Get the link props of a sidebar item (new tab links open in a separate, unlinked tab)
58
55
  const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>[number]['items'][number]) => {
59
- const sharedProps = item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
60
-
61
- // If the link component is an anchor, use href. Otherwise, use to for router links.
62
- if (props.sidebarLinkComponent === 'a') {
63
- return {
64
- href: item.href ?? '#',
65
- ...sharedProps
66
- };
67
- }
68
-
69
- // For non-anchor link components, assume they use a "to" prop (like Vue Router's <RouterLink>).
70
56
  return {
71
- to: item.to ?? item.href ?? '/',
72
- ...sharedProps
57
+ to: item.to,
58
+ ...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})
73
59
  };
74
60
  };
75
61
 
@@ -93,8 +79,9 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
93
79
  <slot name="sidebar-header">
94
80
  <SidebarMenu>
95
81
  <SidebarMenuItem>
82
+ <!-- Home link (the path is localized) -->
96
83
  <SidebarMenuButton as-child size="lg">
97
- <a :href="props.appLinkTo" class="inline-flex items-center gap-2">
84
+ <NuxtLinkLocale :to="props.appLinkTo" class="inline-flex items-center gap-2">
98
85
  <div class="inline-flex items-center gap-2">
99
86
  <!-- App logo -->
100
87
  <template v-if="lightLogo">
@@ -105,7 +92,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
105
92
  <!-- App name -->
106
93
  <span v-if="props.appName" class="text-xl font-semibold tracking-tight text-foreground">{{ props.appName }}</span>
107
94
  </div>
108
- </a>
95
+ </NuxtLinkLocale>
109
96
  </SidebarMenuButton>
110
97
  </SidebarMenuItem>
111
98
  </SidebarMenu>
@@ -125,9 +112,9 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
125
112
  <SidebarMenu :class="cn('gap-1', props.compact && 'gap-0.5')">
126
113
  <SidebarMenuItem v-for="item in section.items" :key="item.id">
127
114
  <SidebarMenuButton as-child :is-active="item.active" :class="cn('group/nav relative h-auto min-h-11 overflow-hidden rounded px-3 py-2.5 text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground data-[active=true]:bg-primary/10 data-[active=true]:text-foreground sm:px-3.5', 'before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:origin-center before:scale-y-0 before:bg-primary before:transition-transform before:duration-300 before:ease-spring data-[active=true]:before:scale-y-100', item.description ? 'items-start' : 'items-center')">
128
- <component :is="props.sidebarLinkComponent" v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
129
- <!-- Icon -->
130
- <HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-0.5 size-5 sm:h-5 sm:w-5' : 'size-5 self-center sm:h-6 sm:w-6')" />
115
+ <NuxtLinkLocale v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
116
+ <!-- Icon, centered on the capitals of the label (or of its first line, 1.25em tall): the line box alone centers it 0.08em too low, because of the space the font keeps for descenders (the size is important: the menu button shrinks its direct svg children to size-4) -->
117
+ <HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('size-5! shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-[calc((1.25em_-_20px)/2_-_0.08em)]' : 'self-center -translate-y-[0.08em]')" />
131
118
 
132
119
  <!-- Label and optional description -->
133
120
  <div :class="cn('min-w-0 flex-1 text-left leading-tight', item.description ? 'grid' : 'flex items-center')">
@@ -139,7 +126,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
139
126
  {{ item.description }}
140
127
  </span>
141
128
  </div>
142
- </component>
129
+ </NuxtLinkLocale>
143
130
  </SidebarMenuButton>
144
131
  </SidebarMenuItem>
145
132
  </SidebarMenu>
@@ -2,16 +2,16 @@
2
2
  import { NuxtImg } from '#components';
3
3
  import FavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';
4
4
  import { HugeiconsIcon } from '@hugeicons/vue';
5
- import { computed, type Component, type HTMLAttributes } from 'vue';
5
+ import { computed, type HTMLAttributes } from 'vue';
6
6
  import { useI18n } from 'vue-i18n';
7
7
  import { cn } from '../../../lib/utils';
8
8
  import type { HeadingLevel } from '../../../lib/common-types';
9
9
 
10
+ // Footer link (the path is localized by the link, external URLs work too)
10
11
  export interface LandingFooterLink {
11
12
  id: string;
12
13
  label: string;
13
- href?: string;
14
- to?: unknown;
14
+ to: string;
15
15
  newTab?: boolean;
16
16
  }
17
17
 
@@ -30,7 +30,6 @@ const props = withDefaults(defineProps<{
30
30
  logoClass?: HTMLAttributes['class'];
31
31
  appDescription?: string;
32
32
  sections?: LandingFooterSection[];
33
- linkComponent?: Component | string;
34
33
  authorName?: string;
35
34
  authorLink?: string;
36
35
  year?: number;
@@ -45,7 +44,6 @@ const props = withDefaults(defineProps<{
45
44
  appLinkTo: '/',
46
45
  appDescription: '',
47
46
  sections: () => [],
48
- linkComponent: 'a',
49
47
  authorName: '',
50
48
  authorLink: '',
51
49
  year: () => new Date().getFullYear(),
@@ -57,22 +55,11 @@ const { t } = useI18n();
57
55
  const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
58
56
  const hasSingleSection = computed(() => props.sections.length === 1);
59
57
 
60
- // Get link props based on the type of link component and whether the link should open in a new tab
58
+ // Get the link props (new tab links open in a separate, unlinked tab)
61
59
  const getLinkProps = (link: LandingFooterLink) => {
62
- const sharedProps = link.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
63
-
64
- // If the link component is an anchor, use href. Otherwise, use to for router links.
65
- if (props.linkComponent === 'a') {
66
- return {
67
- href: link.href ?? '#',
68
- ...sharedProps
69
- };
70
- }
71
-
72
- // For non-anchor link components, assume they use a "to" prop (like Vue Router's <RouterLink>).
73
60
  return {
74
- to: link.to ?? link.href ?? '/',
75
- ...sharedProps
61
+ to: link.to,
62
+ ...(link.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})
76
63
  };
77
64
  };
78
65
 
@@ -88,14 +75,14 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
88
75
  <div :class="cn('flex flex-col gap-8 md:flex-row md:items-start md:justify-between')">
89
76
  <!-- Brand section -->
90
77
  <div :class="cn('max-w-md flex-1', props.brandClass)">
91
- <!-- App logo and name -->
92
- <a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
78
+ <!-- App logo and name (home link, the path is localized) -->
79
+ <NuxtLinkLocale :to="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
93
80
  <template v-if="lightLogo">
94
81
  <NuxtImg :src="lightLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('size-8 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
95
82
  <NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('hidden size-8 shrink-0 object-contain dark:block', props.logoClass)" />
96
83
  </template>
97
84
  <span>{{ props.appName }}</span>
98
- </a>
85
+ </NuxtLinkLocale>
99
86
 
100
87
  <!-- App description -->
101
88
  <p v-if="props.appDescription" class="max-w-sm text-xs md:text-sm leading-relaxed text-muted-foreground">
@@ -114,9 +101,9 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
114
101
  <!-- Section links -->
115
102
  <ul class="space-y-3 text-xs sm:text-sm">
116
103
  <li v-for="link in props.sections[0]?.links" :key="link.id">
117
- <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
104
+ <NuxtLinkLocale v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
118
105
  {{ link.label }}
119
- </component>
106
+ </NuxtLinkLocale>
120
107
  </li>
121
108
  </ul>
122
109
  </div>
@@ -133,9 +120,9 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
133
120
  <!-- Section links -->
134
121
  <ul class="space-y-3 text-xs sm:text-sm">
135
122
  <li v-for="link in section.links" :key="link.id">
136
- <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
123
+ <NuxtLinkLocale v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
137
124
  {{ link.label }}
138
- </component>
125
+ </NuxtLinkLocale>
139
126
  </li>
140
127
  </ul>
141
128
  </div>
@@ -64,7 +64,8 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
64
64
  <!-- Left-aligned content -->
65
65
  <div :class="cn('flex items-center', props.leftClass)">
66
66
  <slot name="left">
67
- <a v-if="lightLogo || props.appName" :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
67
+ <!-- Home link (the path is localized) -->
68
+ <NuxtLinkLocale v-if="lightLogo || props.appName" :to="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
68
69
  <!-- App logo -->
69
70
  <template v-if="lightLogo">
70
71
  <NuxtImg :src="lightLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" fetchpriority="high" :class="cn('size-11 sm:size-9 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
@@ -75,7 +76,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
75
76
  <span v-if="props.appName" class="hidden sm:inline! text-xl font-semibold tracking-tight text-foreground">
76
77
  {{ props.appName }}
77
78
  </span>
78
- </a>
79
+ </NuxtLinkLocale>
79
80
  </slot>
80
81
  </div>
81
82
 
@@ -23,7 +23,8 @@ const hasStructuredSlots = computed(() => {
23
23
  </script>
24
24
 
25
25
  <template>
26
- <div :class="cn('relative flex min-h-screen flex-col bg-background text-foreground', props.rootClass)">
26
+ <!-- Clip the horizontal overflow of decorations (glows, marquees), so the page never scrolls sideways on mobile -->
27
+ <div :class="cn('relative flex min-h-screen flex-col overflow-x-clip bg-background text-foreground', props.rootClass)">
27
28
  <!-- Background grid -->
28
29
  <BackgroundGrid v-if="props.showBackground" />
29
30
 
@@ -1,29 +1,15 @@
1
1
  <script setup lang="ts">
2
- import type { Component } from 'vue';
3
-
4
- // Props
5
- const props = withDefaults(defineProps<{
2
+ // Props (the path is localized by the link, external URLs work too)
3
+ const props = defineProps<{
6
4
  to: string;
7
5
  text: string;
8
- linkComponent?: Component | string;
9
- }>(), {
10
- linkComponent: 'a'
11
- });
6
+ }>();
12
7
 
13
8
  // Emits
14
9
  const emits = defineEmits<{
15
10
  click: [event: MouseEvent];
16
11
  }>();
17
12
 
18
- // Get link props based on the type of link component
19
- const getLinkProps = () => {
20
- if (props.linkComponent === 'a') {
21
- return { href: props.to };
22
- } else {
23
- return { to: props.to };
24
- }
25
- };
26
-
27
13
  // Handle click
28
14
  const handleClick = (event: MouseEvent) => {
29
15
  emits('click', event);
@@ -31,7 +17,7 @@ const handleClick = (event: MouseEvent) => {
31
17
  </script>
32
18
 
33
19
  <template>
34
- <component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
20
+ <NuxtLinkLocale :to="props.to" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
35
21
  {{ props.text }}
36
- </component>
22
+ </NuxtLinkLocale>
37
23
  </template>