@frigear-nu/base 0.1.0 → 0.1.2
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 +15 -0
- package/app/app.config.ts +4 -0
- package/app/app.d.ts +8 -0
- package/app/app.vue +3 -0
- package/app/composables/useCurrentPage.ts +16 -1
- package/app/composables/useLocaleAlternates.ts +74 -0
- package/app/composables/useNavigation.ts +7 -6
- package/app/pages/[[lang]]/[...path].vue +4 -4
- package/app/templates/LandingPage.vue +1 -1
- package/modules/config.ts +11 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -75,11 +75,26 @@ 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.
|
|
81
95
|
- Keep changes here compatible with both consuming apps.
|
|
82
96
|
- If you change module config or public runtime behavior, validate both app consumers in the same change.
|
|
97
|
+
- Client-side navigation: the content page and landing template render `UPageHeader`, `UPageBody` and `ContentRenderer` directly, not their `Lazy*` variants. Every page needs them, and a lazy wrapper costs one more round trip on each navigation. `app.vue` shows `NuxtLoadingIndicator`. `@nuxt/ui` must be ≥ 4.12: older `ULink` renders `NuxtLink` in `custom` mode without its prefetch listeners, so links never prefetch on hover or focus.
|
|
83
98
|
|
|
84
99
|
## Publishing
|
|
85
100
|
|
package/app/app.config.ts
CHANGED
|
@@ -5,6 +5,10 @@ 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: {
|
|
9
13
|
// Shows the colour-mode toggle in the header. Apps whose account menu offers Appearance can turn it off.
|
|
10
14
|
colorMode: true,
|
package/app/app.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ declare module 'nuxt/schema' {
|
|
|
8
8
|
mobile?: boolean
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
seo?: {
|
|
12
|
+
/** Emit hreflang alternates and `og:locale:alternate` on multi-locale sites (default `true`). */
|
|
13
|
+
localeAlternates?: boolean
|
|
14
|
+
}
|
|
11
15
|
socials?: Record<string, string>
|
|
12
16
|
github?: {
|
|
13
17
|
url?: string
|
|
@@ -23,6 +27,10 @@ declare module 'nuxt/schema' {
|
|
|
23
27
|
mobile?: boolean
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
seo?: {
|
|
31
|
+
/** Emit hreflang alternates and `og:locale:alternate` on multi-locale sites (default `true`). */
|
|
32
|
+
localeAlternates?: boolean
|
|
33
|
+
}
|
|
26
34
|
socials?: Record<string, string>
|
|
27
35
|
github?: {
|
|
28
36
|
url?: string
|
package/app/app.vue
CHANGED
|
@@ -9,10 +9,13 @@ useHead({
|
|
|
9
9
|
})
|
|
10
10
|
|
|
11
11
|
usePageSeo()
|
|
12
|
+
useLocaleAlternates()
|
|
12
13
|
</script>
|
|
13
14
|
|
|
14
15
|
<template>
|
|
15
16
|
<UApp>
|
|
17
|
+
<!-- Feedback while a client-side navigation loads; the default throttle hides it on fast ones. -->
|
|
18
|
+
<NuxtLoadingIndicator color="var(--ui-primary)" />
|
|
16
19
|
<NuxtLayout>
|
|
17
20
|
<NuxtPage />
|
|
18
21
|
</NuxtLayout>
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the current route can be a content page. Routes a module registers through `addModulePages` carry
|
|
3
|
+
* `meta.moduleName` and are never served from `@nuxt/content` (account, admin, auth, ...); any other page can opt out
|
|
4
|
+
* with `definePageMeta({ content: false })`. Skipping them avoids a content query (and, in the browser, loading the
|
|
5
|
+
* WASM SQLite dump behind it) on every navigation inside those areas.
|
|
6
|
+
*/
|
|
7
|
+
export const routeServedByContent = (meta: Record<string, unknown>) =>
|
|
8
|
+
meta.content !== false && !meta.moduleName
|
|
9
|
+
|
|
1
10
|
export const useCurrentPage = () => {
|
|
2
11
|
const route = useRoute()
|
|
3
12
|
const { locale, stripLocalePrefix } = useContentLocale()
|
|
@@ -8,10 +17,16 @@ export const useCurrentPage = () => {
|
|
|
8
17
|
() => `current-page:${collection.value}:${path.value}`,
|
|
9
18
|
// Locale-suffixed collections (e.g. `pages_no`) share the same schema as
|
|
10
19
|
// `pages`, so the cast keeps the return type stable across locales.
|
|
11
|
-
() =>
|
|
20
|
+
() => routeServedByContent(route.meta)
|
|
21
|
+
? queryCollection(collection.value as 'pages').path(path.value).first()
|
|
22
|
+
: Promise.resolve(null),
|
|
12
23
|
{
|
|
13
24
|
watch: [path, locale, collection],
|
|
14
25
|
dedupe: 'cancel',
|
|
26
|
+
// Content is static, so a path that was already loaded (revisit) reuses its payload entry instead of querying
|
|
27
|
+
// again. Only an explicit refresh asks the content database.
|
|
28
|
+
getCachedData: (key, nuxtApp, ctx) =>
|
|
29
|
+
ctx.cause === 'refresh:manual' ? undefined : (nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]),
|
|
15
30
|
},
|
|
16
31
|
)
|
|
17
32
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export const useNavigation = (tags?: string[]) => {
|
|
2
|
+
// Resolved in setup: calling a composable inside the handler, after an await, loses the Nuxt context.
|
|
3
|
+
const config = useConfig()
|
|
4
|
+
|
|
2
5
|
return useAsyncData(
|
|
3
6
|
() => `navigation:${tags?.join(',') || 'all'}`,
|
|
4
7
|
async () => {
|
|
5
|
-
const { data
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return (config.value?.navigation || [])
|
|
10
|
-
.filter(item => !tags || tags.some(tag => item?.tags?.includes(tag)))
|
|
8
|
+
const { data } = await config
|
|
9
|
+
const navigation = data.value?.navigation || []
|
|
10
|
+
if (!tags) return navigation
|
|
11
|
+
return navigation.filter(item => tags.some(tag => item?.tags?.includes(tag)))
|
|
11
12
|
},
|
|
12
13
|
)
|
|
13
14
|
}
|
|
@@ -11,18 +11,18 @@ if (!data.value) {
|
|
|
11
11
|
<template>
|
|
12
12
|
<UPage>
|
|
13
13
|
<UContainer v-if="data && data.pageHeader">
|
|
14
|
-
<
|
|
14
|
+
<UPageHeader
|
|
15
15
|
v-bind="data.pageHeader"
|
|
16
16
|
:ui="{ root: 'text-center', wrapper: 'lg:flex-col lg:items-center lg:justify-center' }"
|
|
17
17
|
/>
|
|
18
18
|
</UContainer>
|
|
19
|
-
<
|
|
19
|
+
<UPageBody>
|
|
20
20
|
<component
|
|
21
21
|
:is="data?.container ? UContainer : 'div'"
|
|
22
22
|
v-if="data"
|
|
23
23
|
>
|
|
24
|
-
<
|
|
24
|
+
<ContentRenderer :value="data" />
|
|
25
25
|
</component>
|
|
26
|
-
</
|
|
26
|
+
</UPageBody>
|
|
27
27
|
</UPage>
|
|
28
28
|
</template>
|
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.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@comark/nuxt": "0.6.2",
|
|
27
|
-
"@frigear-nu/menu": "0.1.
|
|
27
|
+
"@frigear-nu/menu": "0.1.2",
|
|
28
28
|
"@iconify-json/lucide": "^1.2.127",
|
|
29
29
|
"@iconify-json/simple-icons": "^1.2.94",
|
|
30
30
|
"@nuxt/content": "^3.16.1",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@nuxt/image": "^2.1.0",
|
|
35
35
|
"@nuxt/kit": "^4.6.1",
|
|
36
36
|
"@nuxt/schema": "^4.6.1",
|
|
37
|
-
"@nuxt/ui": "^4.
|
|
37
|
+
"@nuxt/ui": "^4.12.0",
|
|
38
38
|
"@nuxtjs/device": "4.0.0",
|
|
39
39
|
"@nuxtjs/i18n": "^10.6.0",
|
|
40
40
|
"@nuxtjs/robots": "^7.0.2",
|