@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 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
- () => queryCollection(collection.value as 'pages').path(path.value).first(),
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: config } = await useConfig() // or however you access the pending promise
6
- if (!tags) {
7
- return config.value?.navigation || []
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
- <LazyUPageHeader
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
- <LazyUPageBody>
19
+ <UPageBody>
20
20
  <component
21
21
  :is="data?.container ? UContainer : 'div'"
22
22
  v-if="data"
23
23
  >
24
- <LazyContentRenderer :value="data" />
24
+ <ContentRenderer :value="data" />
25
25
  </component>
26
- </LazyUPageBody>
26
+ </UPageBody>
27
27
  </UPage>
28
28
  </template>
@@ -6,7 +6,7 @@ if (!data.value) {
6
6
  </script>
7
7
 
8
8
  <template>
9
- <LazyContentRenderer
9
+ <ContentRenderer
10
10
  v-if="data"
11
11
  :value="data"
12
12
  />
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.0",
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.0",
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.11.2",
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",