nuxt-seo-utils 8.3.2 → 8.4.0

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 (29) hide show
  1. package/dist/cli.mjs +11 -32
  2. package/dist/devtools/lib/seo-utils/state.ts +5 -2
  3. package/dist/devtools/lib/seo-utils/tools.test.ts +31 -0
  4. package/dist/devtools/lib/seo-utils/tools.ts +42 -3
  5. package/dist/devtools/pages/seo-utils/identity.vue +17 -14
  6. package/dist/module.json +2 -2
  7. package/dist/module.mjs +188 -113
  8. package/dist/runtime/app/composables/useBreadcrumbItems.js +6 -3
  9. package/dist/runtime/app/logic/colorModeIcons.d.ts +3 -0
  10. package/dist/runtime/app/logic/colorModeIcons.js +10 -0
  11. package/dist/runtime/app/plugins/0.routeRules.js +11 -13
  12. package/dist/runtime/app/plugins/colorModeIcons.d.ts +2 -0
  13. package/dist/runtime/app/plugins/colorModeIcons.js +17 -0
  14. package/dist/runtime/app/plugins/minifyScripts.server.js +1 -3
  15. package/dist/runtime/server/middleware/redirectCanonical.d.ts +1 -1
  16. package/dist/runtime/server/middleware/redirectCanonical.js +1 -1
  17. package/dist/runtime/server/middleware/resolveImagesInPagesDir.d.ts +1 -1
  18. package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +1 -1
  19. package/dist/runtime/server/plugins/minifyHtml.d.ts +1 -1
  20. package/dist/runtime/server/plugins/minifyHtml.js +2 -4
  21. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +1 -17
  22. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +3 -2
  23. package/dist/runtime/shared/breadcrumbs.d.ts +8 -0
  24. package/dist/runtime/shared/breadcrumbs.js +10 -0
  25. package/dist/runtime/shared/minify.d.ts +2 -0
  26. package/dist/runtime/shared/minify.js +2 -0
  27. package/dist/runtime/types.d.ts +5 -1
  28. package/dist/shared/nuxt-seo-utils.NAV2YNQn.mjs +94 -0
  29. package/package.json +28 -31
package/dist/cli.mjs CHANGED
@@ -1,4 +1,3 @@
1
- import { Buffer } from 'node:buffer';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { writeFile } from 'node:fs/promises';
4
3
  import process from 'node:process';
@@ -6,9 +5,11 @@ import { loadNuxtConfig } from '@nuxt/kit';
6
5
  import { defineCommand, runMain } from 'citty';
7
6
  import { colors } from 'consola/utils';
8
7
  import { resolve } from 'pathe';
8
+ import { p as pngToIco } from './shared/nuxt-seo-utils.NAV2YNQn.mjs';
9
+ import 'node:buffer';
9
10
 
10
11
  const name = "nuxt-seo-utils";
11
- const version = "8.3.2";
12
+ const version = "8.4.0";
12
13
  const description = "SEO utilities to improve your Nuxt sites discoverability and shareability";
13
14
 
14
15
  const ICON_SIZES = [
@@ -27,33 +28,6 @@ const cwdArgs = {
27
28
  default: "."
28
29
  }
29
30
  };
30
- function pngToIco(pngBuffers) {
31
- const count = pngBuffers.length;
32
- const headerSize = 6;
33
- const dirEntrySize = 16;
34
- const dataOffset = headerSize + dirEntrySize * count;
35
- const header = Buffer.alloc(headerSize);
36
- header.writeUInt16LE(0, 0);
37
- header.writeUInt16LE(1, 2);
38
- header.writeUInt16LE(count, 4);
39
- const dirEntries = [];
40
- let offset = dataOffset;
41
- for (const png of pngBuffers) {
42
- const entry = Buffer.alloc(dirEntrySize);
43
- const size = Math.min(256, 32);
44
- entry.writeUInt8(size === 256 ? 0 : size, 0);
45
- entry.writeUInt8(size === 256 ? 0 : size, 1);
46
- entry.writeUInt8(0, 2);
47
- entry.writeUInt8(0, 3);
48
- entry.writeUInt16LE(1, 4);
49
- entry.writeUInt16LE(32, 6);
50
- entry.writeUInt32LE(png.length, 8);
51
- entry.writeUInt32LE(offset, 12);
52
- dirEntries.push(entry);
53
- offset += png.length;
54
- }
55
- return Buffer.concat([header, ...dirEntries, ...pngBuffers]);
56
- }
57
31
  async function loadSharp() {
58
32
  const mod = await import('sharp').catch(() => {
59
33
  console.error(colors.red("sharp is required for icon generation. Install it with:"));
@@ -107,9 +81,14 @@ const main = defineCommand({
107
81
  await sharp(sourcePath).resize(icon.width, icon.height, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(outputPath);
108
82
  console.log(colors.green(` ${icon.name} (${icon.width}x${icon.height})`));
109
83
  }
110
- const faviconPng = await sharp(sourcePath).resize(32, 32, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
111
- await writeFile(resolve(publicDir, "favicon.ico"), pngToIco([faviconPng]));
112
- console.log(colors.green(" favicon.ico (32x32)"));
84
+ const faviconSizes = [16, 32, 48];
85
+ const faviconPngs = await Promise.all(faviconSizes.map(async (size) => ({
86
+ buffer: await sharp(sourcePath).resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer(),
87
+ width: size,
88
+ height: size
89
+ })));
90
+ await writeFile(resolve(publicDir, "favicon.ico"), pngToIco(faviconPngs));
91
+ console.log(colors.green(" favicon.ico (16x16, 32x32, 48x48)"));
113
92
  console.log(colors.bold(colors.green("\nDone! Generated all icon variants.")));
114
93
  }
115
94
  })
@@ -1,7 +1,7 @@
1
1
  import { ref, watch } from 'vue'
2
2
 
3
3
  export interface DebugData {
4
- runtimeConfig: { version: string, [key: string]: any }
4
+ runtimeConfig: { version: string, appBaseURL: string, [key: string]: any }
5
5
  siteConfig: {
6
6
  url: string
7
7
  name: string
@@ -27,7 +27,10 @@ export async function refreshSources() {
27
27
  return
28
28
  data.value = await appFetch.value('/__nuxt-seo-utils/debug.json', {
29
29
  query: { path: path.value },
30
- }).catch(() => null)
30
+ }).catch(() => {
31
+ // A failed devtools refresh means no debug data is available until the next poll.
32
+ return null
33
+ })
31
34
  }
32
35
 
33
36
  watch([path, appFetch, refreshTime], () => {
@@ -0,0 +1,31 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { getIconIssues, parseMetaTags, resolveAppUrl } from './tools'
5
+
6
+ describe('identity icon inspection', () => {
7
+ it('finds semantic icon rel tokens case-insensitively', () => {
8
+ const parsed = parseMetaTags('<link rel="alternate ICON" href="/favicon.ico" sizes="any">')
9
+
10
+ expect(parsed.iconLinks).toEqual([{
11
+ rel: 'icon',
12
+ href: '/favicon.ico',
13
+ type: undefined,
14
+ sizes: 'any',
15
+ media: undefined,
16
+ }])
17
+ })
18
+
19
+ it('resolves inspected routes within the Nuxt baseURL', () => {
20
+ expect(resolveAppUrl('https://example.com', '/docs/', '/guide')).toBe('https://example.com/docs/guide')
21
+ expect(resolveAppUrl('https://example.com', '/docs/', '/docs/guide')).toBe('https://example.com/docs/guide')
22
+ })
23
+
24
+ it('reports invalid runtime bitmap metadata', () => {
25
+ expect(getIconIssues([{
26
+ rel: 'icon',
27
+ href: 'https://example.com/favicon.ico',
28
+ sizes: 'any',
29
+ }])).toEqual(['https://example.com/favicon.ico uses sizes="any", which is reserved for scalable icons.'])
30
+ })
31
+ })
@@ -7,10 +7,41 @@ export const SEO_LIMITS = {
7
7
  DESC_MAX_PIXELS: 920,
8
8
  } as const
9
9
 
10
+ export interface ParsedIconLink {
11
+ rel: string
12
+ href: string
13
+ type?: string
14
+ sizes?: string
15
+ media?: string
16
+ }
17
+
10
18
  export function estimatePixelWidth(text: string, fontSize: number = 16): number {
11
19
  return Math.round(text.length * fontSize * 0.55)
12
20
  }
13
21
 
22
+ export function resolveAppUrl(origin: string, appBaseURL: string, path: string): string {
23
+ const base = new URL(appBaseURL, origin)
24
+ if (!base.pathname.endsWith('/'))
25
+ base.pathname += '/'
26
+
27
+ const candidate = new URL(path, origin)
28
+ const basePath = base.pathname.replace(/\/$/, '')
29
+ if (candidate.pathname === basePath || candidate.pathname.startsWith(`${basePath}/`))
30
+ return candidate.href
31
+ return new URL(path.replace(/^\//, ''), base).href
32
+ }
33
+
34
+ export function getIconIssues(icons: ParsedIconLink[]): string[] {
35
+ return icons.flatMap((icon) => {
36
+ const isSvg = icon.type === 'image/svg+xml' || /\.svg(?:[?#]|$)/i.test(icon.href)
37
+ if (!icon.sizes)
38
+ return [`${icon.href} has no sizes attribute.`]
39
+ if (icon.sizes.toLowerCase().split(/\s+/).includes('any') && !isSvg)
40
+ return [`${icon.href} uses sizes="any", which is reserved for scalable icons.`]
41
+ return []
42
+ })
43
+ }
44
+
14
45
  export function parseMetaTags(html: string) {
15
46
  const parser = new DOMParser()
16
47
  const doc = parser.parseFromString(html, 'text/html')
@@ -64,13 +95,21 @@ export function parseMetaTags(html: string) {
64
95
  }
65
96
  })
66
97
 
67
- const iconLinks: Array<{ rel: string, href: string, type?: string, sizes?: string, media?: string }> = []
68
- doc.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"], link[rel="shortcut icon"]').forEach((el) => {
98
+ const iconLinks: ParsedIconLink[] = []
99
+ doc.querySelectorAll('link[rel][href]').forEach((el) => {
100
+ const relTokens = (el.getAttribute('rel') || '').toLowerCase().split(/\s+/)
101
+ const rel = relTokens.includes('apple-touch-icon')
102
+ ? 'apple-touch-icon'
103
+ : relTokens.includes('icon')
104
+ ? 'icon'
105
+ : null
106
+ if (!rel)
107
+ return
69
108
  const href = el.getAttribute('href')
70
109
  if (!href)
71
110
  return
72
111
  iconLinks.push({
73
- rel: el.getAttribute('rel') || 'icon',
112
+ rel,
74
113
  href,
75
114
  type: el.getAttribute('type') || undefined,
76
115
  sizes: el.getAttribute('sizes') || undefined,
@@ -1,24 +1,17 @@
1
1
  <script setup lang="ts">
2
+ import type { ParsedIconLink } from '../../lib/seo-utils/tools'
2
3
  import { useClipboard } from '@vueuse/core'
3
4
  import { computed, ref, watch } from 'vue'
4
5
  import { data } from '../../lib/seo-utils/state'
5
- import { parseMetaTags } from '../../lib/seo-utils/tools'
6
+ import { getIconIssues, parseMetaTags, resolveAppUrl } from '../../lib/seo-utils/tools'
6
7
 
7
8
  const { copy } = useClipboard()
8
9
 
9
10
  const loading = ref(false)
10
11
  const error = ref<string | null>(null)
11
12
 
12
- interface IconLink {
13
- rel: string
14
- href: string
15
- type?: string
16
- sizes?: string
17
- media?: string
18
- }
19
-
20
13
  interface ParsedIdentity {
21
- icons: IconLink[]
14
+ icons: ParsedIconLink[]
22
15
  ogImages: Array<{ url: string, width?: string, height?: string, alt?: string }>
23
16
  twitterImages: Array<{ url: string, width?: string, height?: string }>
24
17
  themeColors: Array<{ color: string, media?: string, context: 'light' | 'dark' | 'default' }>
@@ -30,9 +23,10 @@ const parsedPage = ref<ParsedIdentity | null>(null)
30
23
  const siteConfig = computed(() => data.value?.siteConfig || null)
31
24
  const pageIcons = computed(() => parsedPage.value?.icons || [])
32
25
  const hasIcons = computed(() => pageIcons.value.length > 0)
26
+ const iconIssues = computed(() => getIconIssues(pageIcons.value))
33
27
 
34
28
  const iconsByRel = computed(() => {
35
- const groups: Record<string, IconLink[]> = {}
29
+ const groups: Record<string, ParsedIconLink[]> = {}
36
30
  pageIcons.value.forEach((link) => {
37
31
  const rel = link.rel || 'icon'
38
32
  if (!groups[rel])
@@ -110,7 +104,8 @@ async function checkIdentity() {
110
104
 
111
105
  try {
112
106
  const baseUrl = window.parent?.location?.origin || window.location.origin
113
- const response = await fetch(`${baseUrl}${path.value}`, {
107
+ const pageUrl = resolveAppUrl(baseUrl, data.value?.runtimeConfig.appBaseURL || '/', path.value)
108
+ const response = await fetch(pageUrl, {
114
109
  headers: { Accept: 'text/html' },
115
110
  })
116
111
  if (!response.ok)
@@ -155,7 +150,7 @@ async function checkIdentity() {
155
150
  // Resolve relative icon URLs to absolute
156
151
  const icons = parsed.iconLinks.map(icon => ({
157
152
  ...icon,
158
- href: icon.href.startsWith('http') ? icon.href : `${baseUrl}${icon.href.startsWith('/') ? '' : '/'}${icon.href}`,
153
+ href: new URL(icon.href, response.url).href,
159
154
  }))
160
155
 
161
156
  parsedPage.value = {
@@ -182,7 +177,7 @@ watch([() => data.value, path, refreshTime], () => {
182
177
  const cliCommand = 'npx nuxt-seo-utils icons --source <your-logo.svg>'
183
178
 
184
179
  const iconSizesGenerated = [
185
- { name: 'favicon.ico', size: '32x32', desc: 'Browser tab' },
180
+ { name: 'favicon.ico', size: '16x16, 32x32, 48x48', desc: 'Browser tab' },
186
181
  { name: 'icon-16x16.png', size: '16x16', desc: 'Small favicon' },
187
182
  { name: 'icon-32x32.png', size: '32x32', desc: 'Standard favicon' },
188
183
  { name: 'apple-touch-icon.png', size: '180x180', desc: 'iOS home screen' },
@@ -258,6 +253,14 @@ const iconSizesGenerated = [
258
253
  </template>
259
254
 
260
255
  <template v-if="hasIcons">
256
+ <div v-if="iconIssues.length" class="hint-callout mb-4">
257
+ <UIcon name="carbon:warning" class="hint-callout-icon text-sm mt-0.5 shrink-0" />
258
+ <div class="text-xs text-[var(--color-text-muted)]">
259
+ <p v-for="issue in iconIssues" :key="issue">
260
+ {{ issue }}
261
+ </p>
262
+ </div>
263
+ </div>
261
264
  <div v-for="(icons, rel) in iconsByRel" :key="rel" class="mb-5 last:mb-0">
262
265
  <div class="id-group-header">
263
266
  <span>{{ iconRelLabels[rel]?.label || rel }}</span>
package/dist/module.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "8.3.2",
7
+ "version": "8.4.0",
8
8
  "builder": {
9
- "@nuxt/module-builder": "1.0.2",
9
+ "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
11
11
  }
12
12
  }
package/dist/module.mjs CHANGED
@@ -4,16 +4,19 @@ import { useNuxt, addTypeTemplate, useLogger, defineNuxtModule, createResolver,
4
4
  import { defu } from 'defu';
5
5
  import { resolveModulePath } from 'exsolve';
6
6
  import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
7
- import { resolveHostUnheadMajor } from 'nuxtseo-shared/kit';
7
+ import { useModuleLogger, setupNitroRuntimeCompatibility, renderNitroTypeAugmentations, resolveHostUnheadMajor } from 'nuxtseo-shared/kit';
8
8
  import { resolve, extname, relative, dirname, basename } from 'pathe';
9
- import { readPackageJSON } from 'pkg-types';
9
+ import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
10
10
  import { unpackMeta } from '@unhead/vue/utils';
11
11
  import { glob } from 'tinyglobby';
12
12
  import { encodePath, joinURL } from 'ufo';
13
+ import { c as classifyIconFilename, g as getIconRel, n as normalizeIconSizes, a as partitionColorModeIconLinks } from './shared/nuxt-seo-utils.NAV2YNQn.mjs';
13
14
  import escapeRE from 'escape-string-regexp';
14
15
  import { readFile, readdir } from 'node:fs/promises';
15
16
  import imageSize from 'image-size';
17
+ import { JSON_TYPES, SKIP_JS_TYPES } from '../dist/runtime/shared/minify.js';
16
18
  import { setupDevToolsUI as setupDevToolsUI$1 } from 'nuxtseo-shared/devtools';
19
+ import 'node:buffer';
17
20
 
18
21
  function extendNuxtConfigAppHeadSeoMeta(nuxt = useNuxt()) {
19
22
  const seoMeta = nuxt.options.app?.seoMeta || nuxt.options.app?.head?.seoMeta || {};
@@ -73,20 +76,15 @@ export {}
73
76
  }
74
77
 
75
78
  const MetaTagFileDeepGlobs = [
76
- "**/og-image.{png,jpg,jpeg,gif}",
77
- "**/opengraph-image.{png,jpg,jpeg,gif}",
78
- "**/twitter-image.{png,jpg,jpeg,gif}",
79
- "**/apple-icon.{jpg,jpeg,png}",
80
- "**/apple-touch.{jpg,jpeg,png}",
81
- "**/apple-touch-*.{jpg,jpeg,png}",
82
- "**/apple-touch-icon.{jpg,jpeg,png}",
83
- "**/apple-touch-icon-*.{jpg,jpeg,png}",
84
- "**/icon.{ico,jpg,jpeg,png,svg}",
85
- "**/icon-*.{ico,jpg,jpeg,png,svg}"
79
+ "**/{og-image,opengraph-image,twitter-image}.{png,jpg,jpeg,gif}",
80
+ "**/{favicon*,icon*}.{ico,jpg,jpeg,png,svg}",
81
+ "**/*.icon*.{ico,jpg,jpeg,png,svg}",
82
+ "**/apple-*.{jpg,jpeg,png}",
83
+ "**/*.apple-*.{jpg,jpeg,png}"
86
84
  ];
87
- const META_TAG_FILE_RE = /^(?:(?:og-image|opengraph-image)\.(?:png|jpe?g|gif)|twitter-image\.(?:png|jpe?g|gif)|apple-(?:icon|touch|touch-icon)(?:-[^.]+)?\.(?:jpe?g|png)|icon(?:-[^.]+)?\.(?:ico|jpe?g|png|svg)|favicon\.ico)$/;
85
+ const SOCIAL_IMAGE_RE = /^(?:og-image|opengraph-image|twitter-image)\.(?:png|jpe?g|gif)$/;
88
86
  function isMetaTagFile(filename) {
89
- return META_TAG_FILE_RE.test(filename);
87
+ return SOCIAL_IMAGE_RE.test(filename) || classifyIconFilename(filename) !== void 0;
90
88
  }
91
89
 
92
90
  const INDEX_SUFFIX_RE = /-index$/;
@@ -178,7 +176,7 @@ function parseSegment(segment) {
178
176
  state = 0 /* initial */;
179
177
  } else if (PARAM_CHAR_RE.test(c)) {
180
178
  buffer += c;
181
- } else ;
179
+ }
182
180
  break;
183
181
  }
184
182
  i++;
@@ -189,9 +187,6 @@ function parseSegment(segment) {
189
187
  return tokens;
190
188
  }
191
189
 
192
- function hasLinkRel(input, rel) {
193
- return input.link?.some((link) => link.rel === rel);
194
- }
195
190
  function hasMetaProperty(input, property) {
196
191
  return input.meta?.some((meta) => meta.property === property);
197
192
  }
@@ -200,12 +195,12 @@ async function getImageMeta(base, path, isIcon = false) {
200
195
  const absolutePath = resolve(base, path);
201
196
  const file = absolutePath.split("/").pop();
202
197
  const keyword = file.split(".")[0];
203
- let ext = absolutePath.split(".").pop();
198
+ let ext = absolutePath.split(".").pop()?.toLowerCase();
204
199
  if (ext === "jpg")
205
200
  ext = "jpeg";
206
- const { width, height } = await getImageDimensions(absolutePath);
201
+ const { width, height, images } = await getImageDimensions(absolutePath);
207
202
  const payload = {
208
- type: ext === "svg" ? `image/svg+xml` : `image/${ext}`
203
+ type: ext === "svg" ? "image/svg+xml" : ext === "ico" ? "image/vnd.microsoft.icon" : `image/${ext}`
209
204
  };
210
205
  if (!isIcon) {
211
206
  payload.width = width;
@@ -220,7 +215,9 @@ async function getImageMeta(base, path, isIcon = false) {
220
215
  payload.media = "(prefers-color-scheme: dark)";
221
216
  else if (path.includes(".light") || path.includes("-light"))
222
217
  payload.media = "(prefers-color-scheme: light)";
223
- if (ext !== "svg") {
218
+ if (ext === "ico" && images?.length) {
219
+ payload.sizes = [...new Map(images.map((image) => [`${image.width}x${image.height}`, image])).values()].sort((a, b) => a.width * a.height - b.width * b.height).map((image) => `${image.width}x${image.height}`).join(" ");
220
+ } else if (ext !== "svg") {
224
221
  payload.sizes = `${width}x${height}`;
225
222
  } else {
226
223
  payload.sizes = "any";
@@ -234,47 +231,64 @@ async function getImageDimensions(absolutePath) {
234
231
  }
235
232
 
236
233
  const DIR_SUFFIX_RE = /\/_dir$/;
234
+ function addGeneratedRouteRule(nuxt, route, generatedRule) {
235
+ nuxt.options.routeRules ||= {};
236
+ nuxt.options.nitro.routeRules ||= {};
237
+ const routeRules = nuxt.options.routeRules;
238
+ const rule = structuredClone(generatedRule);
239
+ routeRules[route] = defu(routeRules[route], rule);
240
+ const nitroRouteRules = nuxt.options.nitro.routeRules;
241
+ if (nitroRouteRules !== routeRules)
242
+ nitroRouteRules[route] = defu(nitroRouteRules[route], structuredClone(rule));
243
+ }
237
244
  async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
238
245
  const pagesDirs = nuxt.options._layers.map((layer) => resolve(layer.config.rootDir, layer.config.dir?.pages || "pages")).filter((dir) => fs.existsSync(dir));
239
246
  const appendRouteRules = {};
247
+ const registeredFiles = /* @__PURE__ */ new Set();
240
248
  const devMiddlewareMap = {};
241
249
  const nitroOutputMap = [];
242
250
  for (const pagesDir of pagesDirs) {
243
- const files = await glob(MetaTagFileDeepGlobs, { cwd: pagesDir, onlyFiles: true });
251
+ const files = (await glob(MetaTagFileDeepGlobs, { cwd: pagesDir, onlyFiles: true })).filter((file) => isMetaTagFile(basename(file)));
244
252
  for (const file of files) {
245
253
  const fileName = basename(file);
246
254
  const keyword = fileName.split(".")[0] || "";
247
255
  let { path } = generateNuxtPageFromFile(resolve(pagesDir, dirname(file)), pagesDir);
248
- const meta = await getImageMeta(pagesDir, file);
249
256
  if (path.endsWith("/_dir"))
250
257
  path = path.replace(DIR_SUFFIX_RE, "");
251
- const src = joinURL(path, fileName);
252
- if (appendRouteRules[`${path}/**`])
258
+ const registrationKey = `${path}:${fileName}`;
259
+ if (registeredFiles.has(registrationKey))
253
260
  continue;
254
- if (["icon", "apple-touch-icon", "apple-icon"].includes(keyword) || keyword.startsWith("icon-")) {
255
- appendRouteRules[`${path}/**`] = {
256
- head: {
257
- link: [
258
- { rel: keyword, type: meta.type, href: src, sizes: meta.sizes }
259
- ]
260
- }
261
- };
261
+ registeredFiles.add(registrationKey);
262
+ const routeAssetPath = joinURL(path, fileName);
263
+ const href = joinURL(nuxt.options.app.baseURL, routeAssetPath);
264
+ const iconRel = classifyIconFilename(fileName);
265
+ const meta = await getImageMeta(pagesDir, file, iconRel !== void 0);
266
+ const routeRule = appendRouteRules[path] ||= { head: { link: [] }, seoMeta: {} };
267
+ if (iconRel) {
268
+ routeRule.head.link.push({
269
+ rel: iconRel,
270
+ type: String(meta.type),
271
+ href,
272
+ sizes: String(meta.sizes),
273
+ media: meta.media ? String(meta.media) : void 0
274
+ });
262
275
  } else {
263
- appendRouteRules[`${path}/**`] = {
264
- seoMeta: {
265
- [["opengraph-image", "og-image"].includes(keyword) ? "ogImage" : "twitterImage"]: [{ url: src, ...meta, sizes: void 0 }]
266
- }
267
- };
276
+ const property = ["opengraph-image", "og-image"].includes(keyword) ? "ogImage" : "twitterImage";
277
+ routeRule.seoMeta[property] ||= [];
278
+ routeRule.seoMeta[property].push({ url: href, ...meta, sizes: void 0 });
268
279
  }
269
- devMiddlewareMap[src] = resolve(pagesDir, file);
280
+ devMiddlewareMap[routeAssetPath] = resolve(pagesDir, file);
270
281
  nitroOutputMap.push({
271
282
  src: resolve(pagesDir, file),
272
- dest: src.slice(1)
283
+ dest: routeAssetPath.slice(1)
273
284
  });
274
285
  }
275
286
  }
276
- nuxt.options.routeRules = defu(appendRouteRules, nuxt.options.routeRules);
277
- nuxt.options.nitro.routeRules = defu(appendRouteRules, nuxt.options.nitro.routeRules);
287
+ for (const [route, rule] of Object.entries(appendRouteRules)) {
288
+ addGeneratedRouteRule(nuxt, route, rule);
289
+ if (route !== "/")
290
+ addGeneratedRouteRule(nuxt, `${route}/**`, rule);
291
+ }
278
292
  if (nuxt.options.dev) {
279
293
  nuxt.hooks.hook("nitro:config", async (nitroConfig) => {
280
294
  nitroConfig.virtual["#seo-utils-virtual/pageDirImages"] = `export const fileMapping = ${JSON.stringify(devMiddlewareMap)}`;
@@ -294,9 +308,54 @@ async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
294
308
  }
295
309
 
296
310
  async function listMetaTagFiles(dir) {
297
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
311
+ const entries = await readdir(dir, { withFileTypes: true }).catch((error) => {
312
+ if (error.code === "ENOENT")
313
+ return [];
314
+ throw error;
315
+ });
298
316
  return entries.filter((e) => e.isFile() && isMetaTagFile(e.name)).map((e) => e.name);
299
317
  }
318
+ function findLocalIcon(href, baseURL, iconEntries) {
319
+ if (typeof href !== "string" || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(href))
320
+ return;
321
+ const path = href.split(/[?#]/, 1)[0] || "";
322
+ const file = basename(path);
323
+ const entry = iconEntries.find((icon) => icon.file === file);
324
+ if (!entry)
325
+ return;
326
+ const validPaths = /* @__PURE__ */ new Set([entry.file, `/${entry.file}`, joinURL(baseURL, entry.file)]);
327
+ return validPaths.has(path) ? { entry, resolvedHref: `${joinURL(baseURL, entry.file)}${href.slice(path.length)}` } : void 0;
328
+ }
329
+ function enrichConfiguredIcon(link, baseURL, iconEntries, diagnostics) {
330
+ const iconLink = link;
331
+ const match = findLocalIcon(iconLink.href, baseURL, iconEntries);
332
+ if (!match)
333
+ return link;
334
+ const { entry, resolvedHref } = match;
335
+ if (iconLink.href !== resolvedHref) {
336
+ diagnostics.push({
337
+ _tag: "IconHrefNormalized",
338
+ configured: String(iconLink.href),
339
+ resolved: resolvedHref
340
+ });
341
+ }
342
+ const normalizedSizes = normalizeIconSizes(iconLink.sizes, entry.sizes);
343
+ if (normalizedSizes.normalized) {
344
+ diagnostics.push({
345
+ _tag: "IconSizesNormalized",
346
+ href: String(iconLink.href),
347
+ configured: String(iconLink.sizes),
348
+ resolved: entry.sizes
349
+ });
350
+ }
351
+ return {
352
+ ...iconLink,
353
+ href: resolvedHref,
354
+ type: iconLink.type || entry.type,
355
+ sizes: normalizedSizes.sizes,
356
+ media: iconLink.media || entry.media
357
+ };
358
+ }
300
359
  async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
301
360
  const publicDirs = nuxt.options._layers.map((layer) => resolve(layer.config.rootDir, layer.config.dir?.public || "public"));
302
361
  const seen = /* @__PURE__ */ new Set();
@@ -315,37 +374,33 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
315
374
  link: [],
316
375
  meta: []
317
376
  });
318
- if (!hasLinkRel(headConfig, "icon")) {
319
- if (rootPublicFiles.includes("favicon.ico") && nuxt.options.app.baseURL !== "/") {
320
- headConfig.link.push({
321
- rel: "icon",
322
- href: joinURL(nuxt.options.app.baseURL, "favicon.ico"),
323
- sizes: "any"
324
- });
325
- }
326
- const isIcon = (file) => file.includes("icon") && !file.endsWith(".ico");
327
- const isAppleTouchIcon = (file) => file.includes("apple-icon") || file.includes("apple-touch-icon") || file.includes("apple-touch");
328
- const resolveDir = (file) => fileEntries.find((e) => e.file === file).dir;
329
- headConfig.link.push(
330
- ...await Promise.all([
331
- ...rootPublicFiles.filter((file) => isIcon(file) && !isAppleTouchIcon(file)).sort().map(async (iconFile) => {
332
- const meta = await getImageMeta(resolveDir(iconFile), iconFile, true);
333
- return {
334
- rel: "icon",
335
- href: joinURL(nuxt.options.app.baseURL, iconFile),
336
- ...meta
337
- };
338
- }),
339
- ...rootPublicFiles.filter((file) => isAppleTouchIcon(file)).sort().map(async (appleIconFile) => {
340
- const meta = await getImageMeta(resolveDir(appleIconFile), appleIconFile, true);
341
- return {
342
- rel: "apple-touch-icon",
343
- href: joinURL(nuxt.options.app.baseURL, appleIconFile),
344
- ...meta
345
- };
346
- })
347
- ])
348
- );
377
+ const iconEntries = (await Promise.all(fileEntries.map(async ({ file, dir }) => {
378
+ const rel = classifyIconFilename(file);
379
+ if (!rel)
380
+ return;
381
+ const meta = await getImageMeta(dir, file, true);
382
+ return {
383
+ file,
384
+ dir,
385
+ rel,
386
+ type: String(meta.type),
387
+ sizes: String(meta.sizes),
388
+ media: meta.media ? String(meta.media) : void 0
389
+ };
390
+ }))).filter((entry) => entry !== void 0);
391
+ const diagnostics = [];
392
+ headConfig.link = headConfig.link.map((link) => enrichConfiguredIcon(link, nuxt.options.app.baseURL, iconEntries, diagnostics));
393
+ const configuredIconRels = new Set(headConfig.link.map((link) => getIconRel(link.rel)).filter((rel) => rel !== void 0));
394
+ for (const rel of ["icon", "apple-touch-icon"]) {
395
+ if (configuredIconRels.has(rel))
396
+ continue;
397
+ headConfig.link.push(...iconEntries.filter((entry) => entry.rel === rel).sort((a, b) => a.file.localeCompare(b.file)).map((entry) => ({
398
+ rel: entry.rel,
399
+ type: entry.type,
400
+ href: joinURL(nuxt.options.app.baseURL, entry.file),
401
+ sizes: entry.sizes,
402
+ media: entry.media
403
+ })));
349
404
  }
350
405
  let hasTwitterImage = hasMetaProperty(headConfig, "twitter:image");
351
406
  if (!hasTwitterImage) {
@@ -391,12 +446,10 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
391
446
  }
392
447
  }
393
448
  nuxt.options.app.head = headConfig;
394
- const hasIcons = rootPublicFiles.some((f) => f.includes("icon") || f === "favicon.ico");
395
- return { hasIcons };
449
+ const hasIcons = iconEntries.length > 0 || configuredIconRels.size > 0;
450
+ return { hasIcons, diagnostics };
396
451
  }
397
452
 
398
- const JSON_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json"]);
399
- const SKIP_JS_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json", "speculationrules", "importmap"]);
400
453
  const logger = useLogger("nuxt-seo-utils");
401
454
  async function minifyJSBuildTime(code) {
402
455
  try {
@@ -491,11 +544,20 @@ function minifyStaticHead() {
491
544
  });
492
545
  }
493
546
 
547
+ function resolveUnheadVitePluginSource(input, resolvePath) {
548
+ if (input.unheadMajor < 3)
549
+ return { _tag: "unsupported-v2" };
550
+ const path = resolvePath("@unhead/vue/vite", {
551
+ try: true,
552
+ from: [...input.hostImportPaths, ...input.importPaths]
553
+ });
554
+ return path ? { _tag: "vue", url: pathToFileURL(path).href } : { _tag: "missing-vue" };
555
+ }
556
+
494
557
  function setupNuxtConfigAppHeadWithMoreDefaults(nuxt = useNuxt()) {
495
558
  const headConfig = nuxt.options.app.head;
496
559
  headConfig.link = headConfig.link || [];
497
560
  headConfig.htmlAttrs = headConfig.htmlAttrs || {};
498
- headConfig.link = headConfig.link || [];
499
561
  headConfig.meta = headConfig.meta || [];
500
562
  if (!hasMetaProperty(headConfig, "og:type")) {
501
563
  headConfig.meta.push({
@@ -556,20 +618,27 @@ const module$1 = defineNuxtModule({
556
618
  minify: true
557
619
  },
558
620
  async setup(config, nuxt) {
559
- const logger = useLogger("nuxt-seo-utils");
560
- logger.level = config.debug || nuxt.options.debug ? 4 : 3;
621
+ const logger = useModuleLogger("nuxt-seo-utils", config, nuxt);
561
622
  if (config.enabled === false) {
562
623
  logger.debug("The module is disabled, skipping setup.");
563
624
  return;
564
625
  }
626
+ const nitroCompatibility = setupNitroRuntimeCompatibility(nuxt);
565
627
  const { resolve } = createResolver(import.meta.url);
566
628
  const { version } = await readPackageJSON(resolve("../package.json"));
567
629
  await installNuxtSiteConfig();
568
630
  const runtimeDir = resolve("./runtime");
569
631
  if (config.metaDataFiles) {
570
- const { hasIcons } = await generateTagsFromPublicFiles();
632
+ const { hasIcons, diagnostics } = await generateTagsFromPublicFiles();
571
633
  await generateTagsFromPageDirImages();
572
634
  if (nuxt.options.dev) {
635
+ diagnostics.forEach((diagnostic) => {
636
+ if (diagnostic._tag === "IconSizesNormalized") {
637
+ logger.warn(`Normalized icon sizes for ${diagnostic.href} from "${diagnostic.configured}" to "${diagnostic.resolved}" to match the local file.`);
638
+ } else if (diagnostic._tag === "IconHrefNormalized") {
639
+ logger.warn(`Normalized local icon href from "${diagnostic.configured}" to "${diagnostic.resolved}" to include app.baseURL.`);
640
+ }
641
+ });
573
642
  if (!hasIcons) {
574
643
  const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir.public);
575
644
  const logoPatterns = ["logo.svg", "logo.png", "icon.svg", "icon.png", "logo.jpg", "logo.webp"];
@@ -675,25 +744,17 @@ const module$1 = defineNuxtModule({
675
744
  filename: "module/nuxt-seo-utils.d.ts",
676
745
  getContents: (data) => {
677
746
  const typesPath = relative(resolve(data.nuxt.options.rootDir, data.nuxt.options.buildDir, "module"), resolve("runtime/types"));
678
- const types = ` interface NitroRouteRules {
679
- seoMeta?: import('${typesPath}').MetaFlatSerializable
680
- head?: import('${typesPath}').Head
681
- }
682
- interface NitroRouteConfig {
683
- seoMeta?: import('${typesPath}').MetaFlatSerializable
684
- head?: import('${typesPath}').Head
685
- }`;
747
+ const routeRuleTypes = `seoMeta?: import('${typesPath}').MetaFlatSerializable
748
+ head?: import('${typesPath}').Head`;
749
+ const nitroTypes = renderNitroTypeAugmentations(nitroCompatibility, {
750
+ routeRules: routeRuleTypes,
751
+ routeConfig: routeRuleTypes
752
+ });
686
753
  return `// Generated by nuxt-seo-utils
687
754
 
688
755
  import type { RobotsContext } from '#robots/types'
689
756
 
690
- declare module 'nitropack/types' {
691
- ${types}
692
- }
693
-
694
- declare module 'nitropack' {
695
- ${types}
696
- }
757
+ ${nitroTypes}
697
758
 
698
759
 
699
760
  declare module '@nuxt/schema' {
@@ -712,6 +773,18 @@ export {}
712
773
  nuxt: true
713
774
  });
714
775
  const appRuntimeDir = resolve(runtimeDir, "./app");
776
+ nuxt.hook("modules:done", () => {
777
+ if (!hasNuxtModule("@nuxtjs/color-mode", nuxt))
778
+ return;
779
+ const headConfig = nuxt.options.app.head;
780
+ const result = partitionColorModeIconLinks(headConfig.link || []);
781
+ if (result._tag === "StaticIconLinks")
782
+ return;
783
+ headConfig.link = result.links;
784
+ const seoRuntimeConfig = nuxt.options.runtimeConfig.public["seo-utils"];
785
+ seoRuntimeConfig.colorModeIcons = result.icons;
786
+ addPlugin({ src: resolve(appRuntimeDir, "plugins", "colorModeIcons") });
787
+ });
715
788
  nuxt.options.unhead = defu(
716
789
  nuxt.options.unhead,
717
790
  { vite: config.treeShakeUseSeoMeta ? { validate: !nuxt.options.test } : false }
@@ -725,6 +798,12 @@ export {}
725
798
  const lightningcssPath = resolveModulePath("lightningcss", { try: true, from: importPaths });
726
799
  const rolldownURL = rolldownPath ? pathToFileURL(rolldownPath).href : void 0;
727
800
  const lightningcssURL = lightningcssPath ? pathToFileURL(lightningcssPath).href : void 0;
801
+ const hostImportPaths = unheadMajor >= 3 ? [directoryToURL(dirname(await resolvePackageJSON("nuxt", { url: directoryToURL(nuxt.options.rootDir).href })))] : [];
802
+ const vitePluginSource = resolveUnheadVitePluginSource({
803
+ unheadMajor,
804
+ hostImportPaths,
805
+ importPaths
806
+ }, resolveModulePath);
728
807
  const minify = {
729
808
  js: rolldownURL ? async (code) => {
730
809
  const { minify: minify2 } = await import(rolldownURL);
@@ -739,22 +818,18 @@ export {}
739
818
  }).code).trim();
740
819
  } : void 0
741
820
  };
742
- addVitePlugin(async () => {
743
- if (unheadMajor >= 3) {
744
- const v3Vite = "@unhead/vue/vite";
745
- const { Unhead: Unhead2 } = await import(v3Vite);
746
- return Unhead2({ minify, ...viteOpts });
747
- }
748
- const { Unhead } = await import('@unhead/bundler/vite');
749
- return Unhead({
750
- _framework: "@unhead/vue",
751
- minify,
752
- validate: false,
753
- devtools: false
821
+ if (vitePluginSource._tag === "unsupported-v2") {
822
+ logger.warn("`treeShakeUseSeoMeta` requires Unhead v3; skipping the transform on Unhead v2.");
823
+ } else if (vitePluginSource._tag === "missing-vue") {
824
+ logger.warn("`treeShakeUseSeoMeta` requires `@unhead/vue` with the `/vite` export. Install Unhead v3 or disable `treeShakeUseSeoMeta`.");
825
+ } else {
826
+ addVitePlugin(async () => {
827
+ const { Unhead } = await import(vitePluginSource.url);
828
+ return Unhead({ minify, ...viteOpts });
829
+ }, {
830
+ prepend: true
754
831
  });
755
- }, {
756
- prepend: true
757
- });
832
+ }
758
833
  }
759
834
  if (config.automaticOgAndTwitterTags)
760
835
  addPlugin({ src: resolve(appRuntimeDir, "plugins", "inferSeoMetaPlugin") });
@@ -17,7 +17,7 @@ import {
17
17
  import { defineBreadcrumb, useI18n, useSchemaOrg } from "#imports";
18
18
  import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
19
19
  import { createSitePathResolver } from "#site-config/app/composables/utils";
20
- import { pathBreadcrumbSegments } from "../../shared/breadcrumbs.js";
20
+ import { pathBreadcrumbSegments, resolveBreadcrumbRoot } from "../../shared/breadcrumbs.js";
21
21
  function withoutQuery(path) {
22
22
  return path.split("?")[0] || "";
23
23
  }
@@ -120,8 +120,11 @@ export function useBreadcrumbItems(_options = {}) {
120
120
  }, {});
121
121
  let rootNode = flatOptions.rootSegment || "/";
122
122
  if (i18n) {
123
- if (i18n.strategy === "prefix" || i18n.strategy !== "no_prefix" && toValue(i18n.defaultLocale) !== toValue(i18n.locale))
124
- rootNode = `${rootNode}${toValue(i18n.locale)}`;
123
+ rootNode = resolveBreadcrumbRoot(rootNode, {
124
+ locale: toValue(i18n.locale),
125
+ defaultLocale: toValue(i18n.defaultLocale),
126
+ strategy: i18n.strategy
127
+ });
125
128
  }
126
129
  const current = withoutQuery(withoutTrailingSlash(flatOptions.path || toRaw(route)?.path || rootNode)) || "";
127
130
  const segments = pathBreadcrumbSegments(current, rootNode).map((path, index) => {
@@ -0,0 +1,3 @@
1
+ import type { Link } from '@unhead/vue/types';
2
+ import type { ColorModeIconLinks } from '../../types.js';
3
+ export declare function selectColorModeIconLinks(icons: ColorModeIconLinks, colorMode: string): Link[];
@@ -0,0 +1,10 @@
1
+ export function selectColorModeIconLinks(icons, colorMode) {
2
+ const selected = colorMode === "dark" ? icons.dark : icons.light;
3
+ return selected.map((link, index) => ({
4
+ ...link,
5
+ key: `nuxt-seo-utils:color-mode-icon:${getLinkKey(link, index)}`
6
+ }));
7
+ }
8
+ function getLinkKey(link, index) {
9
+ return `${link.rel}:${index}`;
10
+ }
@@ -1,27 +1,25 @@
1
- import { injectHead, useSeoMeta } from "@unhead/vue";
2
- import { defineNuxtPlugin, getRouteRules, useRequestEvent, useRuntimeConfig, useState } from "nuxt/app";
1
+ import { defineNuxtPlugin, useHead, useRequestEvent, useRuntimeConfig, useSeoMeta, useState } from "#imports";
2
+ function parseRouteRuleState(context) {
3
+ const rules = context._nitro?.routeRules;
4
+ return {
5
+ head: rules?.head,
6
+ seoMeta: rules?.seoMeta
7
+ };
8
+ }
3
9
  export default defineNuxtPlugin({
4
10
  enforce: "post",
5
11
  env: { islands: false },
6
- async setup() {
7
- const head = injectHead();
8
- if (!head)
9
- return;
12
+ setup() {
10
13
  const { tagPriority } = useRuntimeConfig().public["seo-utils"];
11
14
  const routeRuleState = useState("nuxt-seo-utils:routeRules", () => null);
12
15
  if (import.meta.server) {
13
16
  const event = useRequestEvent();
14
- const routeRules = await getRouteRules(event);
15
- const rules = routeRules;
16
- routeRuleState.value = {
17
- head: rules.head,
18
- seoMeta: rules.seoMeta
19
- };
17
+ routeRuleState.value = parseRouteRuleState(event?.context);
20
18
  }
21
19
  if (routeRuleState.value) {
22
20
  const { head: headInput, seoMeta } = routeRuleState.value;
23
21
  if (headInput)
24
- head.push(headInput);
22
+ useHead(headInput);
25
23
  if (seoMeta)
26
24
  useSeoMeta(seoMeta, { tagPriority });
27
25
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,17 @@
1
+ import { defineNuxtPlugin, useHead, useNuxtApp, useRuntimeConfig } from "#imports";
2
+ import { selectColorModeIconLinks } from "../logic/colorModeIcons.js";
3
+ export default defineNuxtPlugin({
4
+ name: "nuxt-seo-utils:color-mode-icons",
5
+ enforce: "post",
6
+ setup() {
7
+ const runtimeConfig = useRuntimeConfig();
8
+ const seoConfig = runtimeConfig.public["seo-utils"];
9
+ const colorMode = useNuxtApp().$colorMode;
10
+ const icons = seoConfig.colorModeIcons;
11
+ if (!icons || !colorMode)
12
+ return;
13
+ useHead(() => ({
14
+ link: selectColorModeIconLinks(icons, colorMode.value)
15
+ }));
16
+ }
17
+ });
@@ -1,8 +1,6 @@
1
1
  import { injectHead } from "@unhead/vue";
2
2
  import { defineNuxtPlugin } from "nuxt/app";
3
- import { minifyCSS, minifyJS, minifyJSON } from "../../shared/minify.js";
4
- const JSON_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json"]);
5
- const SKIP_JS_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json", "speculationrules", "importmap"]);
3
+ import { JSON_TYPES, minifyCSS, minifyJS, minifyJSON, SKIP_JS_TYPES } from "../../shared/minify.js";
6
4
  export default defineNuxtPlugin({
7
5
  enforce: "post",
8
6
  setup() {
@@ -1,2 +1,2 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void> | undefined>;
1
+ declare const _default: any;
2
2
  export default _default;
@@ -1,5 +1,5 @@
1
- import { defineEventHandler, getRequestHost, sendRedirect, setHeader } from "h3";
2
1
  import { joinURL } from "ufo";
2
+ import { defineEventHandler, getRequestHost, sendRedirect, setHeader } from "#nuxtseo/h3";
3
3
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
4
4
  export default defineEventHandler((e) => {
5
5
  if (import.meta.prerender) {
@@ -1,2 +1,2 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<NonSharedBuffer | undefined>>;
1
+ declare const _default: any;
2
2
  export default _default;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
- import { defineEventHandler, setHeader } from "h3";
3
2
  import { parseURL } from "ufo";
3
+ import { defineEventHandler, setHeader } from "#nuxtseo/h3";
4
4
  import { fileMapping } from "#seo-utils-virtual/pageDirImages";
5
5
  export default defineEventHandler(async (e) => {
6
6
  const path = parseURL(e.path).pathname;
@@ -1,2 +1,2 @@
1
- declare const _default: import("nitropack/types").NitroAppPlugin;
1
+ declare const _default: any;
2
2
  export default _default;
@@ -1,10 +1,8 @@
1
- import { defineNitroPlugin } from "nitropack/runtime";
2
- import { minifyCSS, minifyJS, minifyJSON } from "../../shared/minify.js";
1
+ import { defineNitroPlugin } from "#nuxtseo/nitro";
2
+ import { JSON_TYPES, minifyCSS, minifyJS, minifyJSON, SKIP_JS_TYPES } from "../../shared/minify.js";
3
3
  const INLINE_SCRIPT_RE = /<script(?![^>]+\bsrc\b)([^>]*)>([\s\S]*?)<\/script\s*>/gi;
4
4
  const INLINE_STYLE_RE = /<style([^>]*)>([\s\S]*?)<\/style\s*>/gi;
5
5
  const TYPE_ATTR_RE = /\btype\s*=\s*["']([^"']*)["']/i;
6
- const JSON_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json"]);
7
- const SKIP_JS_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json", "speculationrules", "importmap"]);
8
6
  function minifyChunk(chunk) {
9
7
  let out = chunk.replace(INLINE_SCRIPT_RE, (full, attrs, content) => {
10
8
  if (!content)
@@ -1,18 +1,2 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, {
2
- runtimeConfig: any;
3
- siteConfig: {
4
- url: string;
5
- name: any;
6
- description: any;
7
- env: any;
8
- indexable: any;
9
- trailingSlash: any;
10
- titleSeparator: any;
11
- defaultLocale: any;
12
- currentLocale: any;
13
- twitter: any;
14
- };
15
- headConfig: any;
16
- path: string | number | true | import("ufo").QueryValue[] | Record<string, any>;
17
- }>;
1
+ declare const _default: any;
18
2
  export default _default;
@@ -1,5 +1,5 @@
1
- import { defineEventHandler, getQuery } from "h3";
2
- import { useRuntimeConfig } from "nitropack/runtime";
1
+ import { defineEventHandler, getQuery } from "#nuxtseo/h3";
2
+ import { useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { getSiteConfig } from "#site-config/server/composables";
4
4
  export default defineEventHandler((e) => {
5
5
  const siteConfig = getSiteConfig(e);
@@ -10,6 +10,7 @@ export default defineEventHandler((e) => {
10
10
  return {
11
11
  runtimeConfig: {
12
12
  version: runtimeConfig.public?.["nuxt-seo-utils-version"] || "unknown",
13
+ appBaseURL: runtimeConfig.app.baseURL,
13
14
  ...seoUtils
14
15
  },
15
16
  siteConfig: {
@@ -1 +1,9 @@
1
+ import type { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime';
2
+ interface BreadcrumbLocaleContext {
3
+ locale?: string;
4
+ defaultLocale?: string;
5
+ strategy?: RuntimeI18nConfig['strategy'];
6
+ }
7
+ export declare function resolveBreadcrumbRoot(rootNode: string, context: BreadcrumbLocaleContext): string;
1
8
  export declare function pathBreadcrumbSegments(path: string, rootNode?: string): string[];
9
+ export {};
@@ -1,4 +1,14 @@
1
+ import { localePath } from "nuxtseo-shared/i18n-runtime";
1
2
  import { hasTrailingSlash, parseURL, stringifyParsedURL, withTrailingSlash } from "ufo";
3
+ export function resolveBreadcrumbRoot(rootNode, context) {
4
+ if (!context.locale || !context.strategy)
5
+ return rootNode;
6
+ return localePath(rootNode, context.locale, {
7
+ defaultLocale: context.defaultLocale || context.locale,
8
+ strategy: context.strategy,
9
+ locales: []
10
+ });
11
+ }
2
12
  export function pathBreadcrumbSegments(path, rootNode = "/") {
3
13
  const startNode = parseURL(path);
4
14
  const appendsTrailingSlash = hasTrailingSlash(startNode.pathname);
@@ -1,3 +1,5 @@
1
+ export declare const JSON_TYPES: Set<string>;
2
+ export declare const SKIP_JS_TYPES: Set<string>;
1
3
  /**
2
4
  * Lightweight JS minifier in pure JS (no native deps).
3
5
  * Strips comments and collapses whitespace while preserving string literals.
@@ -1,3 +1,5 @@
1
+ export const JSON_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json"]);
2
+ export const SKIP_JS_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json", "speculationrules", "importmap"]);
1
3
  export function minifyJS(code) {
2
4
  let result = "";
3
5
  let i = 0;
@@ -1,4 +1,8 @@
1
- import type { Head, MetaFlat, RawInput } from '@unhead/vue/types';
1
+ import type { Head, Link, MetaFlat, RawInput } from '@unhead/vue/types';
2
+ export interface ColorModeIconLinks {
3
+ dark: Link[];
4
+ light: Link[];
5
+ }
2
6
  export type MetaFlatSerializable = MetaFlat & {
3
7
  title?: RawInput<'title'>;
4
8
  titleTemplate?: RawInput<'titleTemplate'>;
@@ -0,0 +1,94 @@
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ const APPLE_ICON_RE = /^(?:[^.]+\.)?apple-(?:icon|touch(?:-icon)?)(?:[.-][^.]+)?\.(?:jpe?g|png)$/i;
4
+ const ICON_RE = /^(?:favicon(?:[.-][^.]+)?\.(?:ico|jpe?g|png|svg)|(?:[^.]+\.)?icon(?:[.-][^.]+)?\.(?:ico|jpe?g|png|svg))$/i;
5
+ const SIZE_RE = /^\d+x\d+$/;
6
+ const COLOR_SCHEME_MEDIA_RE = /^\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)$/i;
7
+ function classifyIconFilename(filename) {
8
+ if (APPLE_ICON_RE.test(filename))
9
+ return "apple-touch-icon";
10
+ if (ICON_RE.test(filename))
11
+ return "icon";
12
+ }
13
+ function getIconRel(rel) {
14
+ if (typeof rel !== "string")
15
+ return;
16
+ const tokens = rel.toLowerCase().split(/\s+/);
17
+ if (tokens.includes("apple-touch-icon"))
18
+ return "apple-touch-icon";
19
+ if (tokens.includes("icon"))
20
+ return "icon";
21
+ }
22
+ function getIconColorMode(media) {
23
+ if (typeof media !== "string")
24
+ return;
25
+ return COLOR_SCHEME_MEDIA_RE.exec(media.trim())?.[1]?.toLowerCase();
26
+ }
27
+ function partitionColorModeIconLinks(links) {
28
+ const candidates = links.map((link, index) => {
29
+ const iconLink = link;
30
+ return {
31
+ index,
32
+ link: iconLink,
33
+ mode: getIconColorMode(iconLink.media),
34
+ rel: getIconRel(iconLink.rel)
35
+ };
36
+ });
37
+ const reactiveRels = /* @__PURE__ */ new Set();
38
+ for (const rel of ["icon", "apple-touch-icon"]) {
39
+ const modes = new Set(candidates.filter((candidate) => candidate.rel === rel).map((candidate) => candidate.mode));
40
+ if (modes.has("dark") && modes.has("light"))
41
+ reactiveRels.add(rel);
42
+ }
43
+ if (reactiveRels.size === 0)
44
+ return { _tag: "StaticIconLinks", links };
45
+ const icons = { dark: [], light: [] };
46
+ const reactiveIndexes = /* @__PURE__ */ new Set();
47
+ for (const candidate of candidates) {
48
+ if (!candidate.rel || !candidate.mode || !reactiveRels.has(candidate.rel))
49
+ continue;
50
+ const { media: _, ...link } = candidate.link;
51
+ icons[candidate.mode].push(link);
52
+ reactiveIndexes.add(candidate.index);
53
+ }
54
+ return {
55
+ _tag: "ReactiveIconLinks",
56
+ links: links.filter((_, index) => !reactiveIndexes.has(index)),
57
+ icons
58
+ };
59
+ }
60
+ function normalizeIconSizes(configured, resolved) {
61
+ if (typeof configured !== "string" || !configured.trim())
62
+ return { sizes: resolved, normalized: false };
63
+ if (resolved === "any")
64
+ return { sizes: configured, normalized: false };
65
+ const resolvedSizes = new Set(resolved.split(/\s+/));
66
+ const configuredSizes = configured.toLowerCase().trim().split(/\s+/);
67
+ const isValidSubset = configuredSizes.every((size) => SIZE_RE.test(size) && resolvedSizes.has(size));
68
+ return isValidSubset ? { sizes: configured, normalized: false } : { sizes: resolved, normalized: true };
69
+ }
70
+ function pngToIco(images) {
71
+ const headerSize = 6;
72
+ const directoryEntrySize = 16;
73
+ const header = Buffer.alloc(headerSize);
74
+ header.writeUInt16LE(0, 0);
75
+ header.writeUInt16LE(1, 2);
76
+ header.writeUInt16LE(images.length, 4);
77
+ let offset = headerSize + directoryEntrySize * images.length;
78
+ const directoryEntries = images.map(({ buffer, width, height }) => {
79
+ const entry = Buffer.alloc(directoryEntrySize);
80
+ entry.writeUInt8(width === 256 ? 0 : width, 0);
81
+ entry.writeUInt8(height === 256 ? 0 : height, 1);
82
+ entry.writeUInt8(0, 2);
83
+ entry.writeUInt8(0, 3);
84
+ entry.writeUInt16LE(1, 4);
85
+ entry.writeUInt16LE(32, 6);
86
+ entry.writeUInt32LE(buffer.length, 8);
87
+ entry.writeUInt32LE(offset, 12);
88
+ offset += buffer.length;
89
+ return entry;
90
+ });
91
+ return Buffer.concat([header, ...directoryEntries, ...images.map((image) => image.buffer)]);
92
+ }
93
+
94
+ export { partitionColorModeIconLinks as a, classifyIconFilename as c, getIconRel as g, normalizeIconSizes as n, pngToIco as p };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-seo-utils",
3
3
  "type": "module",
4
- "version": "8.3.2",
4
+ "version": "8.4.0",
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",
@@ -33,15 +33,14 @@
33
33
  "@unhead/vue",
34
34
  "@unhead/vue/plugins",
35
35
  "@unhead/vue/utils",
36
- "@unhead/vue/vite",
37
- "@unhead/bundler/vite"
36
+ "@unhead/vue/vite"
38
37
  ]
39
38
  },
40
39
  "peerDependencies": {
41
40
  "@unhead/vue": "^2.0.7 || ^3.0.0",
42
41
  "esbuild": ">=0.17.0",
43
42
  "lightningcss": ">=1.20.0",
44
- "nuxt": "^3.0.0 || ^4.0.0",
43
+ "nuxt": "^3.0.0 || ^4.0.0 || ^5.0.0",
45
44
  "rolldown": ">=1.0.0-beta.0",
46
45
  "unhead": "^2.0.7 || ^3.0.0"
47
46
  },
@@ -63,16 +62,15 @@
63
62
  }
64
63
  },
65
64
  "dependencies": {
66
- "@nuxt/kit": "^4.5.0",
67
- "@unhead/bundler": "3.2.1",
65
+ "@nuxt/kit": "^4.5.1",
68
66
  "citty": "^0.2.2",
69
67
  "consola": "^3.4.2",
70
68
  "defu": "^6.1.7",
71
69
  "escape-string-regexp": "^5.0.0",
72
- "exsolve": "^1.1.0",
70
+ "exsolve": "^1.1.1",
73
71
  "image-size": "^2.0.2",
74
- "nuxt-site-config": "^4.1.1",
75
- "nuxtseo-shared": "^5.3.2",
72
+ "nuxt-site-config": "^4.1.4",
73
+ "nuxtseo-shared": "^5.3.11",
76
74
  "pathe": "^2.0.3",
77
75
  "pkg-types": "^2.3.1",
78
76
  "scule": "^1.3.0",
@@ -83,38 +81,36 @@
83
81
  "sharp": "^0.35.3"
84
82
  },
85
83
  "devDependencies": {
86
- "@antfu/eslint-config": "^9.1.0",
87
- "@nuxt/devtools-kit": "4.0.0-alpha.7",
88
- "@nuxt/module-builder": "^1.0.2",
89
- "@nuxt/schema": "^4.5.0",
90
- "@nuxt/test-utils": "^4.0.3",
84
+ "@antfu/eslint-config": "^9.2.0",
85
+ "@nuxt/devtools-kit": "4.0.0-alpha.9",
86
+ "@nuxt/module-builder": "^1.0.3",
87
+ "@nuxt/schema": "^4.5.1",
88
+ "@nuxt/test-utils": "^4.1.0",
91
89
  "@nuxt/ui": "^4.10.0",
92
- "@nuxtjs/i18n": "^10.4.1",
93
- "@types/node": "^26.1.1",
94
- "@unhead/vue": "3.2.1",
90
+ "@nuxtjs/i18n": "^10.5.0",
91
+ "@types/node": "^26.1.2",
92
+ "@unhead/vue": "3.2.3",
95
93
  "@vue/test-utils": "^2.4.11",
96
- "bumpp": "^11.1.0",
94
+ "bumpp": "^12.0.0",
97
95
  "case-police": "^2.2.1",
98
96
  "cheerio": "^1.2.0",
99
- "eslint": "^10.7.0",
100
- "eslint-plugin-harlanzw": "^0.17.0",
101
- "happy-dom": "^20.10.6",
102
- "lint-staged": "^17.0.8",
97
+ "eslint": "^10.8.0",
98
+ "eslint-plugin-harlanzw": "^0.17.1",
99
+ "happy-dom": "^20.11.1",
100
+ "lint-staged": "^17.2.0",
103
101
  "markdownlint-cli": "^0.49.1",
104
- "nuxt": "^4.5.0",
102
+ "nuxt": "^4.5.1",
105
103
  "nuxt-security": "^2.6.0",
106
- "nuxtseo-layer-devtools": "^5.3.2",
107
- "playwright-core": "^1.61.1",
108
- "sass": "^1.101.0",
109
- "semver": "^7.8.5",
110
- "sirv": "^3.0.2",
104
+ "nuxtseo-layer-devtools": "^5.3.6",
105
+ "playwright-core": "^1.62.0",
106
+ "sass": "^1.102.0",
111
107
  "typescript": "6.0.3",
112
108
  "ultrahtml": "^1.7.0",
113
109
  "unbuild": "^3.6.1",
114
- "unhead": "3.2.1",
110
+ "unhead": "3.2.3",
115
111
  "vitest": "^4.1.10",
116
112
  "vue": "^3.5.40",
117
- "vue-tsc": "^3.3.7"
113
+ "vue-tsc": "^3.3.8"
118
114
  },
119
115
  "scripts": {
120
116
  "client:build": "node -e \"require('fs').cpSync('devtools','dist/devtools',{recursive:true})\"",
@@ -126,9 +122,10 @@
126
122
  "lint:fix": "eslint . --fix",
127
123
  "typecheck": "nuxt typecheck",
128
124
  "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/i18n && nuxt prepare test/fixtures/csp",
129
- "test": "vitest",
125
+ "test": "vitest && pnpm test:nuxt5",
130
126
  "test:run": "vitest run --exclude '**/\\_\\_runtime\\_\\_/**'",
131
127
  "test:runtime": "cd test/fixtures/basic && vitest --run",
128
+ "test:nuxt5": "pnpm pack --out test/fixtures/nuxt5/module.tgz && node test/fixtures/nuxt5/run.mjs",
132
129
  "release": "pnpm build && bumpp -x \"npx changelogen --output=CHANGELOG.md\"",
133
130
  "lint:docs": "markdownlint ./docs/content && case-police 'docs/content/**/*.md' *.md",
134
131
  "lint:docs:fix": "markdownlint ./docs/content --fix && case-police 'docs/content/**/*.md' *.md --fix",