@frigear-nu/base 0.0.6 → 0.1.1

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/README.md CHANGED
@@ -75,6 +75,20 @@ localized/non-localized setup, see the [Base Layer docs](../../apps/docs/content
75
75
 
76
76
  Page front matter also accepts `container: true`, which wraps the rendered body in a `UContainer` (use it for plain prose pages such as legal; pages built from `u-page-*` sections manage their own containers). It also accepts `seo: { title, description, image, noindex }`, and `config.yaml` accepts `seo: { titleTemplate, description, image }`; the layer ships no SEO defaults, see the docs.
77
77
 
78
+ ## Languages and hreflang
79
+
80
+ With more than one i18n locale, every page gets `<link rel="alternate" hreflang>` tags for each locale plus
81
+ `x-default`, and `og:locale:alternate` for the other locales (`useLocaleAlternates()`, called from `app.vue`).
82
+
83
+ - The alternates come from the app's `i18n.locales`, through `@nuxtjs/i18n`'s `useLocaleHead()`. The canonical,
84
+ `og:url`, `og:locale` and `<html lang>` stay with `nuxt-seo-utils`.
85
+ - Each locale's `language` (BCP 47) decides its hreflang; a locale without one gets no alternate. Include a region
86
+ (`da-DK`, `en-GB`) for `og:locale:alternate`, which leaves out bare languages such as `en`.
87
+ - Hrefs are absolute like the canonical: from `site.url` when set, otherwise from the request origin. Prerendered
88
+ routes need `site.url` at build time; without it their alternate links are skipped with a warning.
89
+ - Opt out with `seo: { localeAlternates: false }` in `app.config.ts`, for apps that write their own hreflang tags or
90
+ enable i18n's `experimental.strictSeo`.
91
+
78
92
  ## Notes
79
93
 
80
94
  - This package is a shared workspace dependency and is linked via `workspace:*` from apps.
package/app/app.config.ts CHANGED
@@ -5,7 +5,13 @@ export default defineAppConfig({
5
5
  neutral: 'slate',
6
6
  },
7
7
  },
8
+ seo: {
9
+ // Emits hreflang and og:locale:alternate tags on multi-locale sites. Turn off when the app writes its own.
10
+ localeAlternates: true,
11
+ },
8
12
  header: {
13
+ // Shows the colour-mode toggle in the header. Apps whose account menu offers Appearance can turn it off.
14
+ colorMode: true,
9
15
  icon: {
10
16
  desktop: false,
11
17
  mobile: true,
package/app/app.d.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  declare module 'nuxt/schema' {
2
2
  interface AppConfigInput {
3
3
  header?: {
4
+ /** Show the colour-mode toggle in the header (default `true`). */
5
+ colorMode?: boolean
4
6
  icon?: {
5
7
  desktop?: boolean
6
8
  mobile?: boolean
7
9
  }
8
10
  }
11
+ seo?: {
12
+ /** Emit hreflang alternates and `og:locale:alternate` on multi-locale sites (default `true`). */
13
+ localeAlternates?: boolean
14
+ }
9
15
  socials?: Record<string, string>
10
16
  github?: {
11
17
  url?: string
@@ -14,11 +20,17 @@ declare module 'nuxt/schema' {
14
20
 
15
21
  interface AppConfig {
16
22
  header?: {
23
+ /** Show the colour-mode toggle in the header (default `true`). */
24
+ colorMode?: boolean
17
25
  icon?: {
18
26
  desktop?: boolean
19
27
  mobile?: boolean
20
28
  }
21
29
  }
30
+ seo?: {
31
+ /** Emit hreflang alternates and `og:locale:alternate` on multi-locale sites (default `true`). */
32
+ localeAlternates?: boolean
33
+ }
22
34
  socials?: Record<string, string>
23
35
  github?: {
24
36
  url?: string
package/app/app.vue CHANGED
@@ -9,6 +9,7 @@ useHead({
9
9
  })
10
10
 
11
11
  usePageSeo()
12
+ useLocaleAlternates()
12
13
  </script>
13
14
 
14
15
  <template>
@@ -2,6 +2,9 @@
2
2
  const { data: config } = await useConfig()
3
3
  const { data: navigation } = useMenu('main')
4
4
  const multiLocale = useRuntimeConfig().public.multiLocale === true
5
+ // Off when the account menu offers Appearance instead (the footer toggle stays for signed-out visitors).
6
+ const appConfig = useAppConfig()
7
+ const colorModeButton = computed(() => appConfig.header?.colorMode !== false)
5
8
  </script>
6
9
 
7
10
  <template>
@@ -18,7 +21,7 @@ const multiLocale = useRuntimeConfig().public.multiLocale === true
18
21
  <template #right>
19
22
  <AppHeaderCTA />
20
23
  <LanguageSelect v-if="multiLocale" />
21
- <UColorModeButton />
24
+ <UColorModeButton v-if="colorModeButton" />
22
25
  <AppHeaderRight />
23
26
  </template>
24
27
 
@@ -2,17 +2,24 @@
2
2
  import type { ContentNavigationItem } from '@nuxt/content'
3
3
 
4
4
  const { data: config } = await useConfig()
5
+ // The same entries as the desktop header (`main` menu, including items modules add); the content config's
6
+ // `navigation` when the menu has none.
7
+ const { data: menu } = await useMenu('main')
8
+ const appConfig = useAppConfig()
5
9
 
6
10
  const items = computed(() => {
7
- const iconOnMobile = useAppConfig().header?.icon?.mobile
11
+ // The content config's `navigation` is only a list when the host sets one.
12
+ const fallback = Array.isArray(config.value?.navigation) ? config.value.navigation : []
13
+ const source = menu.value?.length ? menu.value : fallback
14
+ const iconOnMobile = appConfig.header?.icon?.mobile
8
15
 
9
16
  if (typeof iconOnMobile === 'boolean' && !iconOnMobile) {
10
- return config.value?.navigation?.map(item => ({
17
+ return source.map(item => ({
11
18
  ...item,
12
19
  icon: undefined,
13
20
  }))
14
21
  } else {
15
- return config.value?.navigation
22
+ return source
16
23
  }
17
24
  })
18
25
 
@@ -21,15 +28,15 @@ const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
21
28
 
22
29
  <template>
23
30
  <div class="mt-4">
24
- <UPageGrid v-if="config?.navigation">
31
+ <UPageGrid v-if="items?.length">
25
32
  <UPageCard
26
- v-for="item in items "
33
+ v-for="item in items"
27
34
  :key="item.label"
28
35
  :title="item.label"
29
36
  :icon="item.icon"
30
37
  :description="item.description"
31
38
  :to="item.to"
32
- class="hober:shadow-lg"
39
+ class="hover:shadow-lg"
33
40
  variant="subtle"
34
41
  :ui="{ title: 'text-xl font-semibold' }"
35
42
  />
@@ -0,0 +1,74 @@
1
+ import { hasProtocol, parseURL, stringifyParsedURL } from 'ufo'
2
+
3
+ let warnedMissingSiteUrl = false
4
+
5
+ /**
6
+ * Adds the i18n language alternates to the head of every page: a
7
+ * `<link rel="alternate" hreflang>` per locale plus `x-default`, and
8
+ * `og:locale:alternate` for the other locales.
9
+ *
10
+ * Only those tags are taken from `useLocaleHead()`. Its canonical, `og:url`
11
+ * and `lang` would duplicate the ones nuxt-seo-utils already sets, so they
12
+ * are dropped. Hrefs are made absolute with the same site resolver
13
+ * nuxt-seo-utils uses for the canonical, so both share `site.url`.
14
+ *
15
+ * Emits nothing on single-locale sites or when the app config sets
16
+ * `seo.localeAlternates: false`. Without a `site.url` the resolver falls
17
+ * back to the request origin; a prerender has neither, and relative hrefs are
18
+ * ignored by search engines, so the links are skipped instead.
19
+ */
20
+ export const useLocaleAlternates = () => {
21
+ const appConfig = useAppConfig()
22
+ const { locales } = useI18n()
23
+ const error = useError()
24
+ const localeHead = useLocaleHead({ dir: false, lang: false, seo: true })
25
+ const resolveUrl = createSitePathResolver({ absolute: true, withBase: true })
26
+
27
+ const enabled = computed(() =>
28
+ appConfig.seo?.localeAlternates !== false && locales.value.length > 1 && !error.value,
29
+ )
30
+
31
+ // i18n prefixes hrefs with its own `baseUrl` when one is set; keep only the
32
+ // path so the site resolver decides the origin.
33
+ const toAbsolute = (href: string) => {
34
+ const { pathname, search, hash } = parseURL(href)
35
+ return resolveUrl(stringifyParsedURL({ pathname, search, hash })).value
36
+ }
37
+
38
+ useHead({
39
+ link: () => {
40
+ if (!enabled.value) return []
41
+ const links = (localeHead.value.link ?? [])
42
+ .flatMap(link => (link.rel === 'alternate' && 'hreflang' in link && link.hreflang && link.href)
43
+ ? [{
44
+ key: `locale-alternate-${link.hreflang}`,
45
+ rel: 'alternate' as const,
46
+ hreflang: link.hreflang,
47
+ href: toAbsolute(link.href),
48
+ }]
49
+ : [])
50
+ if (links.some(link => !hasProtocol(link.href))) {
51
+ if ((import.meta.dev || import.meta.prerender) && !warnedMissingSiteUrl) {
52
+ warnedMissingSiteUrl = true
53
+ console.warn('[@frigear-nu/base] hreflang alternates need an absolute `site.url`; set it (at build time for prerendered routes). Skipping them.')
54
+ }
55
+ return []
56
+ }
57
+ return links
58
+ },
59
+ meta: () => {
60
+ if (!enabled.value) return []
61
+ // Same rule as nuxt-seo-utils' `og:locale`: `ll_TT` only, values
62
+ // without a territory are not valid Open Graph locales.
63
+ return (localeHead.value.meta ?? [])
64
+ .filter(meta => meta.property === 'og:locale:alternate' && meta.content)
65
+ .map(meta => String(meta.content).replace(/-/g, '_'))
66
+ .filter(content => content.includes('_'))
67
+ .map(content => ({
68
+ key: `og-locale-alternate-${content}`,
69
+ property: 'og:locale:alternate',
70
+ content,
71
+ }))
72
+ },
73
+ })
74
+ }
package/modules/config.ts CHANGED
@@ -6,5 +6,15 @@ export default defineNuxtModule({
6
6
  meta: {
7
7
  name: 'config',
8
8
  },
9
- setup() {},
9
+ setup(_options, nuxt) {
10
+ // `useLocaleAlternates()` reads `useLocaleHead()`, which warns on every render without an i18n `baseUrl`.
11
+ // Default it to `site.url`; `@nuxtjs/i18n` merges its own options under this value.
12
+ const options = nuxt.options as typeof nuxt.options & { site?: { url?: string }, i18n?: { baseUrl?: unknown } }
13
+ const siteUrl = options.site?.url
14
+ const publicConfig = nuxt.options.runtimeConfig.public as Record<string, unknown>
15
+ const i18n = (publicConfig.i18n ||= {}) as { baseUrl?: unknown }
16
+ if (siteUrl && !options.i18n?.baseUrl && !i18n.baseUrl) {
17
+ i18n.baseUrl = siteUrl
18
+ }
19
+ },
10
20
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frigear-nu/base",
3
- "version": "0.0.6",
3
+ "version": "0.1.1",
4
4
  "description": "Shared Nuxt layer for Frigear websites - bundled modules, UI defaults, content collections, layouts and routing.",
5
5
  "homepage": "https://docs.frigear.dev/layers/base",
6
6
  "repository": {
@@ -24,17 +24,17 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "@comark/nuxt": "0.6.2",
27
- "@frigear-nu/menu": "0.0.11",
27
+ "@frigear-nu/menu": "0.1.1",
28
28
  "@iconify-json/lucide": "^1.2.127",
29
29
  "@iconify-json/simple-icons": "^1.2.94",
30
- "@nuxt/content": "3.16.0",
30
+ "@nuxt/content": "^3.16.1",
31
31
  "@nuxt/fonts": "^1.0.0",
32
32
  "@nuxt/hints": "^1.1.4",
33
33
  "@nuxt/icon": "^2.5.1",
34
34
  "@nuxt/image": "^2.1.0",
35
- "@nuxt/kit": "^4.6.0",
36
- "@nuxt/schema": "^4.6.0",
37
- "@nuxt/ui": "^4.11.0",
35
+ "@nuxt/kit": "^4.6.1",
36
+ "@nuxt/schema": "^4.6.1",
37
+ "@nuxt/ui": "^4.11.2",
38
38
  "@nuxtjs/device": "4.0.0",
39
39
  "@nuxtjs/i18n": "^10.6.0",
40
40
  "@nuxtjs/robots": "^7.0.2",
@@ -50,11 +50,11 @@
50
50
  "zod": "^4.5.4"
51
51
  },
52
52
  "peerDependencies": {
53
- "nuxt": "^4.6.0"
53
+ "nuxt": "^4.6.1"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@nuxt/eslint": "^1.17.0",
57
- "eslint": "^10.9.1",
57
+ "eslint": "^10.12.0",
58
58
  "nuxt": "^4.6.1",
59
59
  "typescript": "^6.0.3",
60
60
  "vue-tsc": "^3.3.11"