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.
- package/dist/cli.mjs +11 -32
- package/dist/devtools/lib/seo-utils/state.ts +1 -1
- package/dist/devtools/lib/seo-utils/tools.test.ts +31 -0
- package/dist/devtools/lib/seo-utils/tools.ts +42 -3
- package/dist/devtools/pages/seo-utils/identity.vue +17 -14
- package/dist/module.d.mts +1 -1
- package/dist/module.d.ts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +181 -110
- package/dist/runtime/app/composables/useBreadcrumbItems.js +6 -3
- package/dist/runtime/app/logic/colorModeIcons.d.ts +3 -0
- package/dist/runtime/app/logic/colorModeIcons.js +10 -0
- package/dist/runtime/app/plugins/0.routeRules.js +11 -13
- package/dist/runtime/app/plugins/colorModeIcons.d.ts +2 -0
- package/dist/runtime/app/plugins/colorModeIcons.js +17 -0
- package/dist/runtime/server/middleware/redirectCanonical.d.ts +1 -1
- package/dist/runtime/server/middleware/redirectCanonical.js +1 -1
- package/dist/runtime/server/middleware/resolveImagesInPagesDir.d.ts +1 -1
- package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +1 -1
- package/dist/runtime/server/plugins/minifyHtml.d.ts +1 -1
- package/dist/runtime/server/plugins/minifyHtml.js +1 -1
- package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +1 -17
- package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +3 -2
- package/dist/runtime/shared/breadcrumbs.d.ts +8 -0
- package/dist/runtime/shared/breadcrumbs.js +10 -0
- package/dist/runtime/shared/minify.js +47 -34
- package/dist/runtime/types.d.ts +5 -1
- package/dist/shared/nuxt-seo-utils.NAV2YNQn.mjs +94 -0
- 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.
|
|
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
|
|
111
|
-
await
|
|
112
|
-
|
|
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
|
})
|
|
@@ -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:
|
|
68
|
-
doc.querySelectorAll('link[rel
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
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:
|
|
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;
|