nuxt-seo-utils 8.3.3 → 8.4.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 (29) hide show
  1. package/dist/cli.mjs +11 -32
  2. package/dist/devtools/lib/seo-utils/state.ts +1 -1
  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.d.mts +1 -1
  7. package/dist/module.d.ts +1 -1
  8. package/dist/module.json +1 -1
  9. package/dist/module.mjs +181 -110
  10. package/dist/runtime/app/composables/useBreadcrumbItems.js +6 -3
  11. package/dist/runtime/app/logic/colorModeIcons.d.ts +3 -0
  12. package/dist/runtime/app/logic/colorModeIcons.js +10 -0
  13. package/dist/runtime/app/plugins/0.routeRules.js +11 -13
  14. package/dist/runtime/app/plugins/colorModeIcons.d.ts +2 -0
  15. package/dist/runtime/app/plugins/colorModeIcons.js +17 -0
  16. package/dist/runtime/server/middleware/redirectCanonical.d.ts +1 -1
  17. package/dist/runtime/server/middleware/redirectCanonical.js +1 -1
  18. package/dist/runtime/server/middleware/resolveImagesInPagesDir.d.ts +1 -1
  19. package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +1 -1
  20. package/dist/runtime/server/plugins/minifyHtml.d.ts +1 -1
  21. package/dist/runtime/server/plugins/minifyHtml.js +1 -1
  22. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +1 -17
  23. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +3 -2
  24. package/dist/runtime/shared/breadcrumbs.d.ts +8 -0
  25. package/dist/runtime/shared/breadcrumbs.js +10 -0
  26. package/dist/runtime/shared/minify.js +47 -34
  27. package/dist/runtime/types.d.ts +5 -1
  28. package/dist/shared/nuxt-seo-utils.NAV2YNQn.mjs +94 -0
  29. package/package.json +20 -24
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.3";
12
+ const version = "8.4.1";
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
@@ -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.d.mts CHANGED
@@ -121,7 +121,7 @@ interface ModuleOptions {
121
121
  * **Build mode**: Minifies static `app.head` scripts/styles and prerendered route HTML using esbuild (JS) and lightningcss (CSS).
122
122
  * **Runtime mode**: Minifies all inline scripts/styles per SSR request via an Unhead `ssr:render` plugin using lightweight pure JS minifiers.
123
123
  *
124
- * @default true
124
+ * @default { build: true, runtime: false }
125
125
  */
126
126
  minify: boolean | {
127
127
  build?: boolean;
package/dist/module.d.ts CHANGED
@@ -121,7 +121,7 @@ interface ModuleOptions {
121
121
  * **Build mode**: Minifies static `app.head` scripts/styles and prerendered route HTML using esbuild (JS) and lightningcss (CSS).
122
122
  * **Runtime mode**: Minifies all inline scripts/styles per SSR request via an Unhead `ssr:render` plugin using lightweight pure JS minifiers.
123
123
  *
124
- * @default true
124
+ * @default { build: true, runtime: false }
125
125
  */
126
126
  minify: boolean | {
127
127
  build?: boolean;
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "8.3.3",
7
+ "version": "8.4.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"