@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 +14 -0
- package/app/app.config.ts +6 -0
- package/app/app.d.ts +12 -0
- package/app/app.vue +1 -0
- package/app/components/App/AppHeader.vue +4 -1
- package/app/components/App/AppHeaderBody.vue +13 -6
- package/app/composables/useLocaleAlternates.ts +74 -0
- package/modules/config.ts +11 -1
- package/package.json +8 -8
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
|
@@ -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
|
-
|
|
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
|
|
17
|
+
return source.map(item => ({
|
|
11
18
|
...item,
|
|
12
19
|
icon: undefined,
|
|
13
20
|
}))
|
|
14
21
|
} else {
|
|
15
|
-
return
|
|
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="
|
|
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="
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
-
"@nuxt/schema": "^4.6.
|
|
37
|
-
"@nuxt/ui": "^4.11.
|
|
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.
|
|
53
|
+
"nuxt": "^4.6.1"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@nuxt/eslint": "^1.17.0",
|
|
57
|
-
"eslint": "^10.
|
|
57
|
+
"eslint": "^10.12.0",
|
|
58
58
|
"nuxt": "^4.6.1",
|
|
59
59
|
"typescript": "^6.0.3",
|
|
60
60
|
"vue-tsc": "^3.3.11"
|