nuxt-seo-utils 8.5.2 → 8.6.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.
Files changed (38) hide show
  1. package/README.md +9 -4
  2. package/dist/cli.mjs +1 -1
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +25 -20
  5. package/dist/runtime/app/composables/schemaOrg.d.ts +2 -0
  6. package/dist/runtime/app/composables/schemaOrg.js +2 -0
  7. package/dist/runtime/app/composables/useBreadcrumbItems.js +11 -4
  8. package/dist/runtime/app/composables/useFallbackTitle.js +0 -8
  9. package/dist/runtime/app/composables/useShareLinks.js +7 -9
  10. package/dist/runtime/app/logic/applyDefaults.js +19 -21
  11. package/dist/runtime/app/logic/syncDerivedTags.d.ts +14 -0
  12. package/dist/runtime/app/logic/syncDerivedTags.js +43 -0
  13. package/dist/runtime/app/plugins/0.routeRules.d.ts +1 -1
  14. package/dist/runtime/app/plugins/0.routeRules.js +2 -3
  15. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.d.ts +1 -1
  16. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.js +1 -2
  17. package/dist/runtime/app/plugins/colorModeIcons.d.ts +1 -1
  18. package/dist/runtime/app/plugins/colorModeIcons.js +1 -1
  19. package/dist/runtime/app/plugins/defaults.d.ts +1 -1
  20. package/dist/runtime/app/plugins/defaultsWaitI18n.d.ts +1 -1
  21. package/dist/runtime/app/plugins/derivedTags.d.ts +2 -0
  22. package/dist/runtime/app/plugins/derivedTags.js +17 -0
  23. package/dist/runtime/app/plugins/inferSeoMetaPlugin.d.ts +1 -1
  24. package/dist/runtime/app/plugins/inferSeoMetaPlugin.js +1 -2
  25. package/dist/runtime/app/plugins/minifyScripts.server.d.ts +1 -1
  26. package/dist/runtime/app/plugins/minifyScripts.server.js +1 -2
  27. package/dist/runtime/app/plugins/siteConfig.d.ts +1 -1
  28. package/dist/runtime/app/plugins/siteConfig.js +1 -2
  29. package/dist/runtime/app/plugins/titles.d.ts +1 -1
  30. package/dist/runtime/app/plugins/titles.js +1 -2
  31. package/dist/runtime/app/plugins/titlesWaitI18n.d.ts +1 -1
  32. package/dist/runtime/app/plugins/titlesWaitI18n.js +1 -2
  33. package/dist/runtime/shared/canonicalUrl.d.ts +17 -0
  34. package/dist/runtime/shared/canonicalUrl.js +26 -0
  35. package/dist/runtime/shared/derivedTagKeys.d.ts +10 -0
  36. package/dist/runtime/shared/derivedTagKeys.js +2 -0
  37. package/package.json +3 -2
  38. package/skills/nuxt-seo-utils/SKILL.md +144 -0
package/README.md CHANGED
@@ -5,6 +5,14 @@
5
5
  [![License][license-src]][license-href]
6
6
  [![Nuxt][nuxt-src]][nuxt-href]
7
7
 
8
+ <a href="https://skilld.dev/gh/harlan-zw/nuxt-seo-utils">
9
+ <picture>
10
+ <source media="(prefers-color-scheme: dark)" srcset="https://skilld.dev/b/harlan-zw/nuxt-seo-utils?theme=dark">
11
+ <source media="(prefers-color-scheme: light)" srcset="https://skilld.dev/b/harlan-zw/nuxt-seo-utils?theme=light">
12
+ <img alt="Skill repository on skilld.dev" src="https://skilld.dev/b/harlan-zw/nuxt-seo-utils?theme=light">
13
+ </picture>
14
+ </a>
15
+
8
16
  Nuxt SEO Utils is a collection of defaults and utilities to improve your Nuxt site's discoverability and shareability.
9
17
 
10
18
  While there are several features covering many aspects of SEO, it covers important defaults such as [automatic canonical URLs](https://nuxtseo.com/learn/controlling-crawlers/canonical-urls) and
@@ -40,10 +48,7 @@ npx nuxi@latest module add nuxt-seo-utils
40
48
  ```
41
49
 
42
50
  > [!TIP]
43
- > Generate an Agent Skill for this package using [skilld](https://github.com/harlan-zw/skilld):
44
- > ```bash
45
- > npx skilld add nuxt-seo-utils
46
- > ```
51
+ > Using an AI agent? Get the nuxt-seo-utils Skill on [skilld.dev/gh/harlan-zw/nuxt-seo-utils](https://skilld.dev/gh/harlan-zw/nuxt-seo-utils).
47
52
 
48
53
  💡 Set up your meta tags? Check titles, descriptions and OG tags with a SERP preview using the free [Meta Tag Checker](https://nuxtseo.com/tools/meta-tag-checker), or monitor them across every page with [Nuxt SEO Pro](https://nuxtseo.com/pro).
49
54
 
package/dist/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { p as pngToIco } from './shared/nuxt-seo-utils.NAV2YNQn.mjs';
9
9
  import 'node:buffer';
10
10
 
11
11
  const name = "nuxt-seo-utils";
12
- const version = "8.5.2";
12
+ const version = "8.6.1";
13
13
  const description = "SEO utilities to improve your Nuxt sites discoverability and shareability";
14
14
 
15
15
  const ICON_SIZES = [
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "8.5.2",
7
+ "version": "8.6.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -14,6 +14,7 @@ import { c as classifyIconFilename, g as getIconRel, n as normalizeIconSizes, a
14
14
  import escapeRE from 'escape-string-regexp';
15
15
  import { readFile, readdir } from 'node:fs/promises';
16
16
  import { Buffer } from 'node:buffer';
17
+ import { TWITTER_IMAGE_FALLBACK_KEY } from '../dist/runtime/shared/derivedTagKeys.js';
17
18
  import { JSON_TYPES, SKIP_JS_TYPES } from '../dist/runtime/shared/minify.js';
18
19
  import { setupDevToolsUI as setupDevToolsUI$1 } from 'nuxtseo-shared/devtools';
19
20
 
@@ -82,9 +83,15 @@ const MetaTagFileDeepGlobs = [
82
83
  "**/apple-*.{jpg,jpeg,png}",
83
84
  "**/*.apple-*.{jpg,jpeg,png}"
84
85
  ];
85
- const SOCIAL_IMAGE_RE = /^(?:og-image|opengraph-image|twitter-image)\.(?:png|jpe?g|gif)$/;
86
+ const SOCIAL_IMAGE_RE = /^(og-image|opengraph-image|twitter-image)\.(?:png|jpe?g|gif)$/;
87
+ function classifySocialImageFilename(filename) {
88
+ const keyword = SOCIAL_IMAGE_RE.exec(filename)?.[1];
89
+ if (!keyword)
90
+ return;
91
+ return keyword === "twitter-image" ? "twitterImage" : "ogImage";
92
+ }
86
93
  function isMetaTagFile(filename) {
87
- return SOCIAL_IMAGE_RE.test(filename) || classifyIconFilename(filename) !== void 0;
94
+ return classifySocialImageFilename(filename) !== void 0 || classifyIconFilename(filename) !== void 0;
88
95
  }
89
96
 
90
97
  const INDEX_SUFFIX_RE = /-index$/;
@@ -383,10 +390,9 @@ async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
383
390
  const devMiddlewareMap = {};
384
391
  const nitroOutputMap = [];
385
392
  for (const pagesDir of pagesDirs) {
386
- const files = (await glob(MetaTagFileDeepGlobs, { cwd: pagesDir, onlyFiles: true })).filter((file) => isMetaTagFile(basename(file)));
393
+ const files = (await glob(MetaTagFileDeepGlobs, { cwd: pagesDir, onlyFiles: true })).filter((file) => isMetaTagFile(basename(file))).sort();
387
394
  for (const file of files) {
388
395
  const fileName = basename(file);
389
- const keyword = fileName.split(".")[0] || "";
390
396
  let { path } = generateNuxtPageFromFile(resolve(pagesDir, dirname(file)), pagesDir);
391
397
  if (path.endsWith("/_dir"))
392
398
  path = path.replace(DIR_SUFFIX_RE, "");
@@ -408,9 +414,10 @@ async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
408
414
  media: meta.media ? String(meta.media) : void 0
409
415
  });
410
416
  } else {
411
- const property = ["opengraph-image", "og-image"].includes(keyword) ? "ogImage" : "twitterImage";
412
- routeRule.seoMeta[property] ||= [];
413
- routeRule.seoMeta[property].push({ url: href, ...meta, sizes: void 0 });
417
+ const property = classifySocialImageFilename(fileName);
418
+ if (!property || routeRule.seoMeta[property])
419
+ continue;
420
+ routeRule.seoMeta[property] = { url: href, ...meta, alt: meta.alt || "", sizes: void 0 };
414
421
  }
415
422
  devMiddlewareMap[routeAssetPath] = resolve(pagesDir, file);
416
423
  nitroOutputMap.push({
@@ -539,7 +546,7 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
539
546
  }
540
547
  let hasTwitterImage = hasMetaProperty(headConfig, "twitter:image");
541
548
  if (!hasTwitterImage) {
542
- const twitterImageFiles = rootPublicFiles.filter((file) => file.startsWith("twitter-image.")).sort();
549
+ const twitterImageFiles = rootPublicFiles.filter((file) => classifySocialImageFilename(file) === "twitterImage").sort();
543
550
  if (twitterImageFiles.length) {
544
551
  headConfig.meta.push(
545
552
  ...(await Promise.all(twitterImageFiles.map(async (twitterImageFile) => {
@@ -557,25 +564,18 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
557
564
  }
558
565
  }
559
566
  if (!hasMetaProperty(headConfig, "og:image")) {
560
- const ogImageFiles = rootPublicFiles.filter((file) => file.startsWith("og-image.") || file.startsWith("og.")).sort();
567
+ const ogImageFiles = rootPublicFiles.filter((file) => classifySocialImageFilename(file) === "ogImage").sort();
561
568
  if (ogImageFiles.length) {
562
569
  headConfig.meta.push(
563
570
  ...(await Promise.all(ogImageFiles.map(async (src) => {
564
571
  const meta = await getImageMeta(fileEntries.find((e) => e.file === src).dir, src, false);
565
572
  delete meta.sizes;
566
- const seoMeta = {
567
- ogImage: {
568
- url: src,
569
- ...meta
570
- }
571
- };
573
+ const tags = unpackMeta({ ogImage: { url: src, ...meta } });
572
574
  if (!hasTwitterImage) {
573
- seoMeta.twitterImage = {
574
- url: src,
575
- ...meta
576
- };
575
+ const twitterTags = unpackMeta({ twitterImage: { url: src, ...meta } });
576
+ tags.push(...twitterTags.map((tag) => ({ ...tag, key: `${TWITTER_IMAGE_FALLBACK_KEY}:${src}` })));
577
577
  }
578
- return unpackMeta(seoMeta);
578
+ return tags;
579
579
  }))).flat()
580
580
  );
581
581
  }
@@ -1034,6 +1034,9 @@ const module$1 = defineNuxtModule({
1034
1034
  }
1035
1035
  }
1036
1036
  const hasI18n = hasNuxtModule("@nuxtjs/i18n") || hasNuxtModule("nuxt-i18n-micro");
1037
+ addPlugin({
1038
+ src: resolve(runtimeDir, "./app/plugins/derivedTags")
1039
+ });
1037
1040
  if (config.automaticDefaults) {
1038
1041
  if (hasI18n) {
1039
1042
  addPlugin({
@@ -1098,6 +1101,8 @@ const module$1 = defineNuxtModule({
1098
1101
  });
1099
1102
  }
1100
1103
  nuxt.options.alias["#seo-utils"] = runtimeDir;
1104
+ const hasSchemaOrg = hasNuxtModule("nuxt-schema-org") && nuxt.options.schemaOrg?.enable !== false;
1105
+ nuxt.options.alias["#seo-utils-schema"] = hasSchemaOrg ? resolve(runtimeDir, "./app/composables/schemaOrg") : resolve(runtimeDir, "./app/composables/polyfills");
1101
1106
  nuxt.options.runtimeConfig.public["nuxt-seo-utils-version"] = nuxt.options.runtimeConfig.public["nuxt-seo-utils-version"] || version || "";
1102
1107
  nuxt.options.runtimeConfig.public["seo-utils"] = defu(nuxt.options.runtimeConfig.public["seo-utils"] || {}, {
1103
1108
  canonicalQueryWhitelist: config.canonicalQueryWhitelist ?? [
@@ -0,0 +1,2 @@
1
+ export { useSchemaOrg } from '#schema-org/app/composables/useSchemaOrg';
2
+ export { defineBreadcrumb } from '@unhead/schema-org/vue';
@@ -0,0 +1,2 @@
1
+ export { useSchemaOrg } from "#schema-org/app/composables/useSchemaOrg";
2
+ export { defineBreadcrumb } from "@unhead/schema-org/vue";
@@ -1,6 +1,6 @@
1
1
  import { defu } from "defu";
2
2
  import { fixSlashes } from "nuxt-site-config/urls";
3
- import { useNuxtApp, useRoute, useRouter, useState } from "nuxt/app";
3
+ import { useNuxtApp, useRoute, useRouter, useRuntimeConfig, useState } from "nuxt/app";
4
4
  import { withoutTrailingSlash } from "ufo";
5
5
  import {
6
6
  computed,
@@ -14,10 +14,12 @@ import {
14
14
  toValue,
15
15
  watch
16
16
  } from "vue";
17
- import { defineBreadcrumb, useI18n, useSchemaOrg } from "#imports";
17
+ import { defineBreadcrumb, useSchemaOrg } from "#seo-utils-schema";
18
18
  import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
19
19
  import { createSitePathResolver } from "#site-config/app/composables/utils";
20
20
  import { pathBreadcrumbSegments, resolveBreadcrumbRoot } from "../../shared/breadcrumbs.js";
21
+ import { normaliseCanonicalUrl } from "../../shared/canonicalUrl.js";
22
+ import { useI18n as useI18nPolyfill } from "./polyfills.js";
21
23
  function withoutQuery(path) {
22
24
  return path.split("?")[0] || "";
23
25
  }
@@ -77,7 +79,7 @@ export function useBreadcrumbItems(_options = {}) {
77
79
  }
78
80
  const route = useRoute();
79
81
  const router = useRouter();
80
- const i18n = useI18n();
82
+ const i18n = nuxtApp.$i18n || useI18nPolyfill();
81
83
  const siteResolver = createSitePathResolver({
82
84
  canonical: true,
83
85
  absolute: true
@@ -182,12 +184,17 @@ export function useBreadcrumbItems(_options = {}) {
182
184
  }, { immediate: true, flush: "sync" });
183
185
  const schemaOrgEnabled = typeof _options.schemaOrg === "undefined" ? true : _options.schemaOrg;
184
186
  if ((import.meta.server || import.meta.env?.NODE_ENV === "test") && schemaOrgEnabled) {
187
+ const { canonicalLowercase } = useRuntimeConfig().public["seo-utils"];
188
+ const schemaItemUrl = (to) => normaliseCanonicalUrl(toValue(siteResolver(to)), {
189
+ lowercase: canonicalLowercase,
190
+ locale: toValue(siteConfig.currentLocale) || toValue(siteConfig.defaultLocale)
191
+ });
185
192
  useSchemaOrg([
186
193
  defineBreadcrumb({
187
194
  "@id": `#${id}`,
188
195
  "itemListElement": computed(() => items.value.map((item) => ({
189
196
  name: item.label || item.ariaLabel,
190
- item: item.to ? siteResolver(item.to) : void 0
197
+ item: item.to ? schemaItemUrl(String(item.to)) : void 0
191
198
  })))
192
199
  })
193
200
  ]);
@@ -2,7 +2,6 @@ import { useError, useNuxtApp, useRoute } from "nuxt/app";
2
2
  import { titleCase } from "scule";
3
3
  import { withoutTrailingSlash } from "ufo";
4
4
  import { computed } from "vue";
5
- import { useI18n } from "#imports";
6
5
  export function useFallbackTitle() {
7
6
  const route = useRoute();
8
7
  const err = useError();
@@ -11,13 +10,6 @@ export function useFallbackTitle() {
11
10
  i18n = useNuxtApp().$i18n;
12
11
  } catch {
13
12
  }
14
- if (!i18n) {
15
- try {
16
- i18n = useI18n();
17
- } catch {
18
- console.warn("[nuxt-seo-utils] useFallbackTitle: could not resolve the i18n instance. Titles will not be resolved from i18n messages.");
19
- }
20
- }
21
13
  return computed(() => {
22
14
  if (err.value?.statusCode && [404, 500].includes(err.value.statusCode)) {
23
15
  return `${err.value.statusCode} - ${err.value.message}`;
@@ -1,8 +1,9 @@
1
1
  import { useRoute, useRuntimeConfig } from "nuxt/app";
2
- import { stringifyQuery, withQuery } from "ufo";
2
+ import { withQuery } from "ufo";
3
3
  import { computed, toValue } from "vue";
4
4
  import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
5
5
  import { createSitePathResolver } from "#site-config/app/composables/utils";
6
+ import { resolveCanonicalUrl } from "../../shared/canonicalUrl.js";
6
7
  function buildShareUrl(platform, url, title, options) {
7
8
  const encodedUrl = encodeURIComponent(url);
8
9
  const encodedTitle = encodeURIComponent(title);
@@ -53,14 +54,11 @@ export function useShareLinks(options = {}) {
53
54
  if (customUrl) {
54
55
  canonicalUrl = customUrl;
55
56
  } else {
56
- let url = resolveUrl(route.path || "/").value || route.path;
57
- if (canonicalLowercase) {
58
- url = url.toLowerCase();
59
- }
60
- const filteredQuery = Object.fromEntries(
61
- Object.entries(route.query).filter(([key]) => canonicalQueryWhitelist.includes(key)).sort(([a], [b]) => a.localeCompare(b))
62
- );
63
- canonicalUrl = Object.keys(filteredQuery).length ? `${url}?${stringifyQuery(filteredQuery)}` : url;
57
+ canonicalUrl = resolveCanonicalUrl(resolveUrl(route.path || "/").value || route.path, route.query, {
58
+ lowercase: canonicalLowercase,
59
+ locale: toValue(siteConfig.currentLocale) || toValue(siteConfig.defaultLocale),
60
+ queryWhitelist: canonicalQueryWhitelist
61
+ });
64
62
  }
65
63
  const utm = toValue(options.utm) ?? true;
66
64
  const title = toValue(options.title) || siteConfig.name || "";
@@ -1,10 +1,10 @@
1
- import { injectHead, useHead, useSeoMeta } from "@unhead/vue";
2
1
  import { TemplateParamsPlugin } from "@unhead/vue/plugins";
3
- import { useError, useRoute, useRuntimeConfig } from "nuxt/app";
4
- import { stringifyQuery } from "ufo";
2
+ import { injectHead, useError, useHead, useRoute, useRuntimeConfig, useSeoMeta } from "nuxt/app";
5
3
  import { computed, toValue } from "vue";
6
4
  import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
7
5
  import { createSitePathResolver } from "#site-config/app/composables/utils";
6
+ import { resolveCanonicalUrl } from "../../shared/canonicalUrl.js";
7
+ import { OG_URL_KEY } from "../../shared/derivedTagKeys.js";
8
8
  const LOCALE_UNDERSCORE_RE = /_/g;
9
9
  export function applyDefaults() {
10
10
  const siteConfig = useSiteConfig({
@@ -26,20 +26,12 @@ export function applyDefaults() {
26
26
  if (err.value) {
27
27
  return false;
28
28
  }
29
- const { query } = route;
30
- let url = resolveUrl(route.path || "/").value || route.path;
31
- if (canonicalLowercase) {
32
- try {
33
- url = url.toLocaleLowerCase(resolveCurrentLocale());
34
- } catch {
35
- url = url.toLowerCase();
36
- }
37
- }
38
- const filteredQuery = Object.fromEntries(
39
- Object.entries(query).filter(([key]) => canonicalQueryWhitelist.includes(key)).sort(([a], [b]) => a.localeCompare(b))
40
- // Sort params
41
- );
42
- const href = Object.keys(filteredQuery).length ? `${url}?${stringifyQuery(filteredQuery)}` : url;
29
+ const url = resolveUrl(route.path || "/").value || route.path;
30
+ const href = resolveCanonicalUrl(url, route.query, {
31
+ lowercase: canonicalLowercase,
32
+ locale: resolveCurrentLocale(),
33
+ queryWhitelist: canonicalQueryWhitelist
34
+ });
43
35
  return { rel: "canonical", href };
44
36
  });
45
37
  const minimalPriority = {
@@ -74,10 +66,6 @@ export function applyDefaults() {
74
66
  }, minimalPriority);
75
67
  const seoMeta = {
76
68
  ogType: "website",
77
- ogUrl: () => {
78
- const url = canonicalUrl.value;
79
- return url ? url.href : false;
80
- },
81
69
  ogSiteName: siteConfig.name
82
70
  };
83
71
  if (import.meta.server && siteConfig.description)
@@ -88,4 +76,14 @@ export function applyDefaults() {
88
76
  seoMeta.twitterSite = id;
89
77
  }
90
78
  useSeoMeta(seoMeta, seoMetaPriority);
79
+ useHead({
80
+ meta: [{
81
+ property: "og:url",
82
+ content: () => {
83
+ const url = canonicalUrl.value;
84
+ return url ? url.href : false;
85
+ },
86
+ key: OG_URL_KEY
87
+ }]
88
+ }, seoMetaPriority);
91
89
  }
@@ -0,0 +1,14 @@
1
+ import type { HeadTag } from '@unhead/vue/types';
2
+ /**
3
+ * Returns the entries whose tags hold an `og:image`, before deduplication.
4
+ */
5
+ export declare function ogImageEntryIds(entryTags: Iterable<HeadTag[]>): Set<number>;
6
+ /**
7
+ * Keeps tags that describe another tag consistent with the tag that won deduplication.
8
+ *
9
+ * - `og:image:*` tags from an entry whose `og:image` lost are dropped, so a page `ogImage`
10
+ * does not keep the width, height, or type of the `public/` image.
11
+ * - `twitter:image` tags copied from a `public/` og-image follow the same rule.
12
+ * - The default `og:url` takes the href of the resolved canonical link.
13
+ */
14
+ export declare function syncDerivedTags(tags: HeadTag[], entriesWithOgImage: Set<number>): HeadTag[];
@@ -0,0 +1,43 @@
1
+ import { OG_URL_KEY, TWITTER_IMAGE_FALLBACK_KEY } from "../../shared/derivedTagKeys.js";
2
+ function entryOf(tag) {
3
+ return tag._p === void 0 ? void 0 : tag._p >> 10;
4
+ }
5
+ function metaKey(tag) {
6
+ const key = tag.props.property ?? tag.props.name;
7
+ return typeof key === "string" ? key : void 0;
8
+ }
9
+ function isOgImage(tag) {
10
+ return tag.tag === "meta" && metaKey(tag) === "og:image";
11
+ }
12
+ export function ogImageEntryIds(entryTags) {
13
+ const ids = /* @__PURE__ */ new Set();
14
+ for (const tags of entryTags) {
15
+ const tag = tags.find(isOgImage);
16
+ const id = tag && entryOf(tag);
17
+ if (id !== void 0)
18
+ ids.add(id);
19
+ }
20
+ return ids;
21
+ }
22
+ export function syncDerivedTags(tags, entriesWithOgImage) {
23
+ const winningOgImageEntries = new Set(tags.filter(isOgImage).map(entryOf));
24
+ const canonicalHref = tags.find((tag) => tag.tag === "link" && tag.props.rel === "canonical")?.props.href;
25
+ const lostOgImage = (tag) => {
26
+ const entry = entryOf(tag);
27
+ return entry !== void 0 && entriesWithOgImage.has(entry) && !winningOgImageEntries.has(entry);
28
+ };
29
+ return tags.flatMap((tag) => {
30
+ if (tag.tag !== "meta")
31
+ return [tag];
32
+ if (metaKey(tag)?.startsWith("og:image:") && lostOgImage(tag))
33
+ return [];
34
+ if (tag.key?.startsWith(TWITTER_IMAGE_FALLBACK_KEY) && lostOgImage(tag))
35
+ return [];
36
+ if (tag.key === OG_URL_KEY) {
37
+ if (typeof canonicalHref !== "string" || !canonicalHref)
38
+ return [];
39
+ return [{ ...tag, props: { ...tag.props, content: canonicalHref } }];
40
+ }
41
+ return [tag];
42
+ });
43
+ }
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,4 +1,4 @@
1
- import { defineNuxtPlugin, useHead, useRequestEvent, useRuntimeConfig, useSeoMeta, useState } from "#imports";
1
+ import { defineNuxtPlugin, useHead, useRequestEvent, useSeoMeta, useState } from "nuxt/app";
2
2
  function parseRouteRuleState(context) {
3
3
  const rules = context._nitro?.routeRules;
4
4
  return {
@@ -10,7 +10,6 @@ export default defineNuxtPlugin({
10
10
  enforce: "post",
11
11
  env: { islands: false },
12
12
  setup() {
13
- const { tagPriority } = useRuntimeConfig().public["seo-utils"];
14
13
  const routeRuleState = useState("nuxt-seo-utils:routeRules", () => null);
15
14
  if (import.meta.server) {
16
15
  const event = useRequestEvent();
@@ -21,7 +20,7 @@ export default defineNuxtPlugin({
21
20
  if (headInput)
22
21
  useHead(headInput);
23
22
  if (seoMeta)
24
- useSeoMeta(seoMeta, { tagPriority });
23
+ useSeoMeta(seoMeta);
25
24
  }
26
25
  }
27
26
  });
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,5 +1,4 @@
1
- import { injectHead } from "@unhead/vue";
2
- import { defineNuxtPlugin } from "nuxt/app";
1
+ import { defineNuxtPlugin, injectHead } from "nuxt/app";
3
2
  import { unref } from "vue";
4
3
  import { createSitePathResolver } from "#site-config/app/composables/utils";
5
4
  export default defineNuxtPlugin({
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,4 +1,4 @@
1
- import { defineNuxtPlugin, useHead, useNuxtApp, useRuntimeConfig } from "#imports";
1
+ import { defineNuxtPlugin, useHead, useNuxtApp, useRuntimeConfig } from "nuxt/app";
2
2
  import { selectColorModeIconLinks } from "../logic/colorModeIcons.js";
3
3
  export default defineNuxtPlugin({
4
4
  name: "nuxt-seo-utils:color-mode-icons",
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,17 @@
1
+ import { defineNuxtPlugin, injectHead } from "nuxt/app";
2
+ import { ogImageEntryIds, syncDerivedTags } from "../logic/syncDerivedTags.js";
3
+ export default defineNuxtPlugin({
4
+ name: "nuxt-seo:derived-tags",
5
+ setup() {
6
+ const head = injectHead();
7
+ head.use({
8
+ key: "nuxt-seo-utils:derived-tags",
9
+ hooks: {
10
+ "tags:beforeResolve": (ctx) => {
11
+ const entryTags = [...head.entries.values()].map((entry) => entry._tags || entry._precomputedTags || []);
12
+ ctx.tags = syncDerivedTags(ctx.tags, ogImageEntryIds(entryTags));
13
+ }
14
+ }
15
+ });
16
+ }
17
+ });
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,6 +1,5 @@
1
- import { injectHead } from "@unhead/vue";
2
1
  import { InferSeoMetaPlugin, TemplateParamsPlugin } from "@unhead/vue/plugins";
3
- import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
2
+ import { defineNuxtPlugin, injectHead, useRuntimeConfig } from "nuxt/app";
4
3
  const disabledTwitterCard = "x-nuxt-seo-utils-disabled-twitter-card";
5
4
  export default defineNuxtPlugin(() => {
6
5
  const head = injectHead();
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,5 +1,4 @@
1
- import { injectHead } from "@unhead/vue";
2
- import { defineNuxtPlugin } from "nuxt/app";
1
+ import { defineNuxtPlugin, injectHead } from "nuxt/app";
3
2
  import { JSON_TYPES, minifyCSS, minifyJS, minifyJSON, SKIP_JS_TYPES } from "../../shared/minify.js";
4
3
  export default defineNuxtPlugin({
5
4
  enforce: "post",
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,5 +1,4 @@
1
- import { injectHead } from "@unhead/vue";
2
- import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
1
+ import { defineNuxtPlugin, injectHead, useRuntimeConfig } from "nuxt/app";
3
2
  import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
4
3
  export default defineNuxtPlugin(() => {
5
4
  const head = injectHead();
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,5 +1,4 @@
1
- import { useHead } from "@unhead/vue";
2
- import { defineNuxtPlugin } from "nuxt/app";
1
+ import { defineNuxtPlugin, useHead } from "nuxt/app";
3
2
  import { useFallbackTitle } from "../composables/useFallbackTitle.js";
4
3
  export default defineNuxtPlugin({
5
4
  name: "nuxt-seo:fallback-titles",
@@ -1,2 +1,2 @@
1
- declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -1,5 +1,4 @@
1
- import { useHead } from "@unhead/vue";
2
- import { defineNuxtPlugin } from "nuxt/app";
1
+ import { defineNuxtPlugin, useHead } from "nuxt/app";
3
2
  import { useFallbackTitle } from "../composables/useFallbackTitle.js";
4
3
  export default defineNuxtPlugin({
5
4
  name: "nuxt-seo:fallback-titles",
@@ -0,0 +1,17 @@
1
+ import type { QueryObject } from 'ufo';
2
+ export interface CanonicalUrlOptions {
3
+ lowercase: boolean;
4
+ /** BCP 47 or underscore locale. Lowercasing follows its rules, for example Turkish dotless ı. */
5
+ locale?: string;
6
+ }
7
+ /**
8
+ * The one normaliser for absolute URLs that name the page: the canonical link, og:url,
9
+ * share links, and Schema.org breadcrumb items. Navigation links never pass through it.
10
+ */
11
+ export declare function normaliseCanonicalUrl(url: string, { lowercase, locale }: CanonicalUrlOptions): string;
12
+ /**
13
+ * Normalises the page URL and keeps only whitelisted query keys, sorted.
14
+ */
15
+ export declare function resolveCanonicalUrl(url: string, query: QueryObject, options: CanonicalUrlOptions & {
16
+ queryWhitelist: string[];
17
+ }): string;
@@ -0,0 +1,26 @@
1
+ import { parseURL, stringifyParsedURL, stringifyQuery } from "ufo";
2
+ const LOCALE_UNDERSCORE_RE = /_/g;
3
+ const DEFAULT_LOCALE = "en";
4
+ export function normaliseCanonicalUrl(url, { lowercase, locale }) {
5
+ if (!lowercase)
6
+ return url;
7
+ const parsed = parseURL(url);
8
+ if (parsed.host)
9
+ parsed.host = parsed.host.toLowerCase();
10
+ parsed.pathname = lowercasePath(parsed.pathname, locale);
11
+ return stringifyParsedURL(parsed);
12
+ }
13
+ function lowercasePath(path, locale) {
14
+ try {
15
+ return path.toLocaleLowerCase(locale ? locale.replace(LOCALE_UNDERSCORE_RE, "-") : DEFAULT_LOCALE);
16
+ } catch {
17
+ return path.toLowerCase();
18
+ }
19
+ }
20
+ export function resolveCanonicalUrl(url, query, options) {
21
+ const normalised = normaliseCanonicalUrl(url, options);
22
+ const filteredQuery = Object.fromEntries(
23
+ Object.entries(query).filter(([key]) => options.queryWhitelist.includes(key)).sort(([a], [b]) => a.localeCompare(b))
24
+ );
25
+ return Object.keys(filteredQuery).length ? `${normalised}?${stringifyQuery(filteredQuery)}` : normalised;
26
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Marks the `twitter:image` tags copied from a `public/` og-image.
3
+ * They are dropped when another entry's `og:image` replaces that image.
4
+ * Keys start with this prefix and end with the file name, so two files never merge.
5
+ */
6
+ export declare const TWITTER_IMAGE_FALLBACK_KEY = "nuxt-seo-utils:twitter-image-fallback";
7
+ /**
8
+ * Marks the default `og:url`. It takes the href of the resolved canonical link.
9
+ */
10
+ export declare const OG_URL_KEY = "nuxt-seo-utils:og-url";
@@ -0,0 +1,2 @@
1
+ export const TWITTER_IMAGE_FALLBACK_KEY = "nuxt-seo-utils:twitter-image-fallback";
2
+ export const OG_URL_KEY = "nuxt-seo-utils:og-url";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-seo-utils",
3
3
  "type": "module",
4
- "version": "8.5.2",
4
+ "version": "8.6.1",
5
5
  "description": "SEO utilities to improve your Nuxt sites discoverability and shareability",
6
6
  "license": "MIT",
7
7
  "funding": "https://github.com/sponsors/harlan-zw",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "bin",
28
- "dist"
28
+ "dist",
29
+ "skills"
29
30
  ],
30
31
  "build": {
31
32
  "externals": [
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: nuxt-seo-utils
3
+ description: Set up, override, and debug the default SEO head tags in a Nuxt app with the nuxt-seo-utils module. Use when a task mentions canonical URLs, the title template or separator, fallback titles, og:image or twitter:image files, favicons and app icons, seo.meta, seoMeta or head in routeRules, useBreadcrumbItems, useShareLinks, the nuxt-seo-utils icons CLI, or the seo config key. Also use when a page tag does not override a default, html lang ignores useHead, or og:image renders twice.
4
+ ---
5
+
6
+ # nuxt-seo-utils
7
+
8
+ Tested against `nuxt-seo-utils` 8.5.2 on Nuxt 4.5.2 with Unhead v3 (requires Nuxt `>=3.16.0`).
9
+ The module adds default head tags from site config, reads icon and social image files, and ships breadcrumb and share link composables.
10
+ Docs: https://nuxtseo.com/docs/seo-utils
11
+
12
+ ## Setup
13
+
14
+ The config key is `seo`. `seoUtils` does nothing.
15
+ Set `site.url` and `site.name`. The canonical URL, `og:url`, the title template, and absolute image URLs read them.
16
+
17
+ ```ts
18
+ export default defineNuxtConfig({
19
+ modules: ['nuxt-seo-utils'],
20
+ site: { url: 'https://example.com', name: 'Acme', description: 'Default description', twitter: 'acme', defaultLocale: 'en-US' },
21
+ })
22
+ ```
23
+
24
+ Crawlers read only the SSR response. The defaults render on the server.
25
+
26
+ ## Automatic behaviour
27
+
28
+ With the defaults, every page gets:
29
+
30
+ - `<title>` from the template `%s %separator %siteName`. The separator is `|`. Change it with `site.separator`.
31
+ - A fallback title from the last path segment, title cased: `/blog/my-post` becomes `My Post`. `definePageMeta({ title })` sets it with a static string.
32
+ - `<link rel="canonical">` from `site.url` and the route path, lowercased, with only these query keys kept: `page`, `sort`, `filter`, `search`, `q`, `category`, `tag`.
33
+ - `og:url` (the resolved canonical, including one a page sets), `og:type` (`website`), `og:site_name` (`site.name`), and `description` from `site.description`.
34
+ - `og:title`, `og:description`, and `twitter:card` (`summary_large_image`), inferred from the title and description.
35
+ - `twitter:site` and `twitter:creator` from `site.twitter`. The `@` is added.
36
+ - `<html lang>` from `site.currentLocale` or `site.defaultLocale`, else `en`.
37
+ - `og:locale` only when the locale has a region. `en-US` renders `en_US`. `en` renders no tag.
38
+ - Relative `og:image` and `twitter:image` values become absolute against `site.url`.
39
+
40
+ Module tags use `tagPriority: 'low'`, so a page `useSeoMeta()` or `useHead()` call replaces them.
41
+ A page `ogImage` also drops the default image's width, height, type, and copied `twitter:image`. A page `ogImageAlt` alone keeps the default image.
42
+ A page canonical needs no extra priority:
43
+
44
+ ```ts
45
+ useHead({ link: [{ rel: 'canonical', href: 'https://example.com/preferred' }] })
46
+ ```
47
+
48
+ Set `seo: { automaticDefaults: false }` to drop the canonical, title template, `lang`, and Open Graph defaults together. No option drops only one of them.
49
+
50
+ ## Metadata files
51
+
52
+ Files in `public/` become site-wide head tags at build time:
53
+
54
+ - `favicon.{ico,png,svg}`, `icon*.{ico,png,jpg,svg}`, `apple-icon*.png`, `apple-touch-icon*.png`: `<link rel="icon">` or `apple-touch-icon`, with `type` and `sizes` read from the file.
55
+ - `og-image.{png,jpg,jpeg,gif}` or `opengraph-image.*`: `og:image` with width, height, and type. It also fills `twitter:image` when no `twitter-image.*` file exists. The name must match exactly; `blog-og-image.png` is ignored.
56
+ - `<name>.alt.txt` next to an image sets `og:image:alt`.
57
+ - `-dark` or `-light` in an icon name adds a `prefers-color-scheme` media query.
58
+
59
+ The same file names inside `pages/<route>/` or `pages/<route>/_dir/` apply to that route and its children. They replace a `public/` image there.
60
+ An icon link in `app.head.link` with the same `rel` wins over the files.
61
+ Set `seo: { metaDataFiles: false }` to turn this off.
62
+
63
+ Generate icon files from one source image in `public/`:
64
+
65
+ ```bash
66
+ pnpm exec nuxt-seo-utils icons --source logo.svg
67
+ ```
68
+
69
+ It writes `favicon.ico`, `apple-touch-icon.png`, and `icon-{16,32,192,512}` PNGs to `public/`. It needs `sharp`, which installs as an optional dependency.
70
+
71
+ ## Site-wide and per-route meta
72
+
73
+ `seo.meta` takes `useSeoMeta()` input and renders on every page:
74
+
75
+ ```ts
76
+ export default defineNuxtConfig({
77
+ seo: { meta: { author: 'Acme Team', themeColor: '#18181b' } },
78
+ })
79
+ ```
80
+
81
+ `routeRules` accept `seoMeta` and `head`:
82
+
83
+ ```ts
84
+ export default defineNuxtConfig({
85
+ routeRules: {
86
+ '/blog/**': { seoMeta: { author: 'Blog Team' }, head: { meta: [{ name: 'x-section', content: 'blog' }] } },
87
+ },
88
+ })
89
+ ```
90
+
91
+ Route rule tags override `seo.meta` and `app.head`. A page `useSeoMeta()` or `useHead()` call overrides them. The `tagPriority` option does not change this.
92
+ Route rule tags render on the server only. They do not update on client navigation.
93
+
94
+ ## Breadcrumbs
95
+
96
+ `useBreadcrumbItems()` is auto-imported. It returns a ref of items from the route path, ready for Nuxt UI `<UBreadcrumb :items>`.
97
+
98
+ ```ts
99
+ const items = useBreadcrumbItems({
100
+ overrides: [undefined, undefined, { label: post.value.title }], // index matches the path segment; false removes it
101
+ })
102
+ ```
103
+
104
+ - With `nuxt-schema-org` installed, it also adds a `BreadcrumbList` node. Without it, no JSON-LD renders and there is no warning.
105
+ - Labels come from `definePageMeta({ breadcrumb: { label } })`, then `title` in page meta, then the title-cased segment. The index route is `Home`.
106
+ - Call it in the page component, not the layout. The page has the data for the last items. Calls in parent components with the same `id` merge their options.
107
+
108
+ ## Share links
109
+
110
+ ```ts
111
+ const share = useShareLinks({ title: post.value.title, utm: { source: 'auto', campaign: 'launch' } })
112
+ // share.value.twitter, .facebook, .linkedin, .whatsapp, .telegram, .reddit, .pinterest, .email, .canonicalUrl
113
+ ```
114
+
115
+ `title` defaults to `site.name`, not the page title. Pass it.
116
+ UTM defaults to `utm_source=<platform>` and `utm_medium=social` (or `email`). An object without `source: 'auto'` switches to manual mode: `{ campaign: 'launch' }` sends only `utm_campaign`.
117
+
118
+ ## Traps
119
+
120
+ - **`htmlAttrs.lang` in `useHead()` or `app.head` is ignored.** The module sets `lang` from site config. Set `site.defaultLocale`, or use `@nuxtjs/i18n`.
121
+ - **Before nuxt-schema-org 6.4.1, the Schema.org WebPage URL keeps the route case.** `canonicalLowercase` lowercases the canonical, `og:url`, `useShareLinks()` URLs, and `BreadcrumbList` item URLs. nuxt-schema-org before 6.4.1 builds the WebPage `url` from the route path. There, `/blog/My-Post` gets a canonical of `/blog/my-post` and a WebPage URL of `/blog/My-Post`. From 6.4.1, the WebPage `url` follows the canonical. On an older version, upgrade nuxt-schema-org, use lowercase route paths, or set `canonicalLowercase: false`. Breadcrumb `to` links keep their case on purpose.
122
+ - **`definePageMeta({ title })` takes a static string only.** Use `useSeoMeta({ title })` for data.
123
+
124
+ ## Version limits
125
+
126
+ - 8.5.2 and earlier: a page `ogImage` keeps the default image's dimensions and `twitter:image`, a page canonical leaves `og:url` on the route URL, a `pages/<route>/og-image.png` renders `og:image:alt` twice, and `public/opengraph-image.*` is ignored.
127
+ - 8.5.2 and earlier: `BreadcrumbList` item URLs keep the route case, and `useShareLinks()` lowercases without the site locale.
128
+ - 8.5.2 and earlier: `seo.meta` and `public/og-image.png` beat route rule `seoMeta` and `pages/**/og-image.png`. Put the route value in the page with `useSeoMeta()`.
129
+ - `treeShakeUseSeoMeta` needs Unhead v3. On Unhead v2 the module skips it with a warning.
130
+ - `automaticTwitterTags: false` (removes `twitter:card`) arrived in 8.5.0.
131
+
132
+ ## Config
133
+
134
+ - `redirectToCanonicalSiteUrl` (`false`): in production, send a 301 from other hosts to the `site.url` host.
135
+ - `canonicalQueryWhitelist`: replaces the default list. It does not extend it.
136
+ - `minify` (`{ build: true, runtime: false }`): minify inline scripts and styles in `app.head` and prerendered HTML. `runtime: true` minifies on every SSR request.
137
+ - `automaticOgAndTwitterTags` (`true`): the `og:title`, `og:description`, and `twitter:card` inference.
138
+ - Other options: https://nuxtseo.com/docs/seo-utils/api/config
139
+
140
+ ## Debug
141
+
142
+ - On `nuxt dev` and `nuxt build`, the module warns about conflicts in `app.head`, such as a static `lang` or an `og:site_name` that differs from `site.name`. Fix the site config, not the head. Set `validateAppHead: false` to hide the check.
143
+ - `/__nuxt-seo-utils/debug.json` in dev, or in production with `seo: { debug: true }`.
144
+ - Nuxt DevTools has an SEO Utils tab.