nuxt-seo-utils 8.3.3 → 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.
- 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.json +1 -1
- package/dist/module.mjs +180 -109
- 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/types.d.ts +5 -1
- package/dist/shared/nuxt-seo-utils.NAV2YNQn.mjs +94 -0
- package/package.json +6 -10
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.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
|
|
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.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import fs, { existsSync } from 'node:fs';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
|
-
import { useNuxt, addTypeTemplate,
|
|
3
|
+
import { useNuxt, addTypeTemplate, useLogger, defineNuxtModule, createResolver, addServerHandler, hasNuxtModule, addPlugin, addImports, hasNuxtCompatibility, directoryToURL, addVitePlugin } from '@nuxt/kit';
|
|
4
4
|
import { defu } from 'defu';
|
|
5
5
|
import { resolveModulePath } from 'exsolve';
|
|
6
6
|
import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
|
|
7
|
-
import { useModuleLogger, 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';
|
|
16
17
|
import { JSON_TYPES, SKIP_JS_TYPES } from '../dist/runtime/shared/minify.js';
|
|
17
18
|
import { setupDevToolsUI as setupDevToolsUI$1 } from 'nuxtseo-shared/devtools';
|
|
19
|
+
import 'node:buffer';
|
|
18
20
|
|
|
19
21
|
function extendNuxtConfigAppHeadSeoMeta(nuxt = useNuxt()) {
|
|
20
22
|
const seoMeta = nuxt.options.app?.seoMeta || nuxt.options.app?.head?.seoMeta || {};
|
|
@@ -74,20 +76,15 @@ export {}
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const MetaTagFileDeepGlobs = [
|
|
77
|
-
"**/og-image.{png,jpg,jpeg,gif}",
|
|
78
|
-
"**/
|
|
79
|
-
"
|
|
80
|
-
"**/apple
|
|
81
|
-
"
|
|
82
|
-
"**/apple-touch-*.{jpg,jpeg,png}",
|
|
83
|
-
"**/apple-touch-icon.{jpg,jpeg,png}",
|
|
84
|
-
"**/apple-touch-icon-*.{jpg,jpeg,png}",
|
|
85
|
-
"**/icon.{ico,jpg,jpeg,png,svg}",
|
|
86
|
-
"**/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}"
|
|
87
84
|
];
|
|
88
|
-
const
|
|
85
|
+
const SOCIAL_IMAGE_RE = /^(?:og-image|opengraph-image|twitter-image)\.(?:png|jpe?g|gif)$/;
|
|
89
86
|
function isMetaTagFile(filename) {
|
|
90
|
-
return
|
|
87
|
+
return SOCIAL_IMAGE_RE.test(filename) || classifyIconFilename(filename) !== void 0;
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
const INDEX_SUFFIX_RE = /-index$/;
|
|
@@ -190,9 +187,6 @@ function parseSegment(segment) {
|
|
|
190
187
|
return tokens;
|
|
191
188
|
}
|
|
192
189
|
|
|
193
|
-
function hasLinkRel(input, rel) {
|
|
194
|
-
return input.link?.some((link) => link.rel === rel);
|
|
195
|
-
}
|
|
196
190
|
function hasMetaProperty(input, property) {
|
|
197
191
|
return input.meta?.some((meta) => meta.property === property);
|
|
198
192
|
}
|
|
@@ -201,12 +195,12 @@ async function getImageMeta(base, path, isIcon = false) {
|
|
|
201
195
|
const absolutePath = resolve(base, path);
|
|
202
196
|
const file = absolutePath.split("/").pop();
|
|
203
197
|
const keyword = file.split(".")[0];
|
|
204
|
-
let ext = absolutePath.split(".").pop();
|
|
198
|
+
let ext = absolutePath.split(".").pop()?.toLowerCase();
|
|
205
199
|
if (ext === "jpg")
|
|
206
200
|
ext = "jpeg";
|
|
207
|
-
const { width, height } = await getImageDimensions(absolutePath);
|
|
201
|
+
const { width, height, images } = await getImageDimensions(absolutePath);
|
|
208
202
|
const payload = {
|
|
209
|
-
type: ext === "svg" ?
|
|
203
|
+
type: ext === "svg" ? "image/svg+xml" : ext === "ico" ? "image/vnd.microsoft.icon" : `image/${ext}`
|
|
210
204
|
};
|
|
211
205
|
if (!isIcon) {
|
|
212
206
|
payload.width = width;
|
|
@@ -221,7 +215,9 @@ async function getImageMeta(base, path, isIcon = false) {
|
|
|
221
215
|
payload.media = "(prefers-color-scheme: dark)";
|
|
222
216
|
else if (path.includes(".light") || path.includes("-light"))
|
|
223
217
|
payload.media = "(prefers-color-scheme: light)";
|
|
224
|
-
if (ext
|
|
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") {
|
|
225
221
|
payload.sizes = `${width}x${height}`;
|
|
226
222
|
} else {
|
|
227
223
|
payload.sizes = "any";
|
|
@@ -235,47 +231,64 @@ async function getImageDimensions(absolutePath) {
|
|
|
235
231
|
}
|
|
236
232
|
|
|
237
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
|
+
}
|
|
238
244
|
async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
|
|
239
245
|
const pagesDirs = nuxt.options._layers.map((layer) => resolve(layer.config.rootDir, layer.config.dir?.pages || "pages")).filter((dir) => fs.existsSync(dir));
|
|
240
246
|
const appendRouteRules = {};
|
|
247
|
+
const registeredFiles = /* @__PURE__ */ new Set();
|
|
241
248
|
const devMiddlewareMap = {};
|
|
242
249
|
const nitroOutputMap = [];
|
|
243
250
|
for (const pagesDir of pagesDirs) {
|
|
244
|
-
const files = await glob(MetaTagFileDeepGlobs, { cwd: pagesDir, onlyFiles: true });
|
|
251
|
+
const files = (await glob(MetaTagFileDeepGlobs, { cwd: pagesDir, onlyFiles: true })).filter((file) => isMetaTagFile(basename(file)));
|
|
245
252
|
for (const file of files) {
|
|
246
253
|
const fileName = basename(file);
|
|
247
254
|
const keyword = fileName.split(".")[0] || "";
|
|
248
255
|
let { path } = generateNuxtPageFromFile(resolve(pagesDir, dirname(file)), pagesDir);
|
|
249
|
-
const meta = await getImageMeta(pagesDir, file);
|
|
250
256
|
if (path.endsWith("/_dir"))
|
|
251
257
|
path = path.replace(DIR_SUFFIX_RE, "");
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
258
|
+
const registrationKey = `${path}:${fileName}`;
|
|
259
|
+
if (registeredFiles.has(registrationKey))
|
|
254
260
|
continue;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
});
|
|
263
275
|
} else {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
};
|
|
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 });
|
|
269
279
|
}
|
|
270
|
-
devMiddlewareMap[
|
|
280
|
+
devMiddlewareMap[routeAssetPath] = resolve(pagesDir, file);
|
|
271
281
|
nitroOutputMap.push({
|
|
272
282
|
src: resolve(pagesDir, file),
|
|
273
|
-
dest:
|
|
283
|
+
dest: routeAssetPath.slice(1)
|
|
274
284
|
});
|
|
275
285
|
}
|
|
276
286
|
}
|
|
277
|
-
for (const [route, rule] of Object.entries(appendRouteRules))
|
|
278
|
-
|
|
287
|
+
for (const [route, rule] of Object.entries(appendRouteRules)) {
|
|
288
|
+
addGeneratedRouteRule(nuxt, route, rule);
|
|
289
|
+
if (route !== "/")
|
|
290
|
+
addGeneratedRouteRule(nuxt, `${route}/**`, rule);
|
|
291
|
+
}
|
|
279
292
|
if (nuxt.options.dev) {
|
|
280
293
|
nuxt.hooks.hook("nitro:config", async (nitroConfig) => {
|
|
281
294
|
nitroConfig.virtual["#seo-utils-virtual/pageDirImages"] = `export const fileMapping = ${JSON.stringify(devMiddlewareMap)}`;
|
|
@@ -295,9 +308,54 @@ async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
|
|
|
295
308
|
}
|
|
296
309
|
|
|
297
310
|
async function listMetaTagFiles(dir) {
|
|
298
|
-
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
|
+
});
|
|
299
316
|
return entries.filter((e) => e.isFile() && isMetaTagFile(e.name)).map((e) => e.name);
|
|
300
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
|
+
}
|
|
301
359
|
async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
|
|
302
360
|
const publicDirs = nuxt.options._layers.map((layer) => resolve(layer.config.rootDir, layer.config.dir?.public || "public"));
|
|
303
361
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -316,37 +374,33 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
|
|
|
316
374
|
link: [],
|
|
317
375
|
meta: []
|
|
318
376
|
});
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
};
|
|
347
|
-
})
|
|
348
|
-
])
|
|
349
|
-
);
|
|
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
|
+
})));
|
|
350
404
|
}
|
|
351
405
|
let hasTwitterImage = hasMetaProperty(headConfig, "twitter:image");
|
|
352
406
|
if (!hasTwitterImage) {
|
|
@@ -392,8 +446,8 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
|
|
|
392
446
|
}
|
|
393
447
|
}
|
|
394
448
|
nuxt.options.app.head = headConfig;
|
|
395
|
-
const hasIcons =
|
|
396
|
-
return { hasIcons };
|
|
449
|
+
const hasIcons = iconEntries.length > 0 || configuredIconRels.size > 0;
|
|
450
|
+
return { hasIcons, diagnostics };
|
|
397
451
|
}
|
|
398
452
|
|
|
399
453
|
const logger = useLogger("nuxt-seo-utils");
|
|
@@ -490,6 +544,16 @@ function minifyStaticHead() {
|
|
|
490
544
|
});
|
|
491
545
|
}
|
|
492
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
|
+
|
|
493
557
|
function setupNuxtConfigAppHeadWithMoreDefaults(nuxt = useNuxt()) {
|
|
494
558
|
const headConfig = nuxt.options.app.head;
|
|
495
559
|
headConfig.link = headConfig.link || [];
|
|
@@ -559,14 +623,22 @@ const module$1 = defineNuxtModule({
|
|
|
559
623
|
logger.debug("The module is disabled, skipping setup.");
|
|
560
624
|
return;
|
|
561
625
|
}
|
|
626
|
+
const nitroCompatibility = setupNitroRuntimeCompatibility(nuxt);
|
|
562
627
|
const { resolve } = createResolver(import.meta.url);
|
|
563
628
|
const { version } = await readPackageJSON(resolve("../package.json"));
|
|
564
629
|
await installNuxtSiteConfig();
|
|
565
630
|
const runtimeDir = resolve("./runtime");
|
|
566
631
|
if (config.metaDataFiles) {
|
|
567
|
-
const { hasIcons } = await generateTagsFromPublicFiles();
|
|
632
|
+
const { hasIcons, diagnostics } = await generateTagsFromPublicFiles();
|
|
568
633
|
await generateTagsFromPageDirImages();
|
|
569
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
|
+
});
|
|
570
642
|
if (!hasIcons) {
|
|
571
643
|
const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir.public);
|
|
572
644
|
const logoPatterns = ["logo.svg", "logo.png", "icon.svg", "icon.png", "logo.jpg", "logo.webp"];
|
|
@@ -672,25 +744,17 @@ const module$1 = defineNuxtModule({
|
|
|
672
744
|
filename: "module/nuxt-seo-utils.d.ts",
|
|
673
745
|
getContents: (data) => {
|
|
674
746
|
const typesPath = relative(resolve(data.nuxt.options.rootDir, data.nuxt.options.buildDir, "module"), resolve("runtime/types"));
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
head?: import('${typesPath}').Head
|
|
682
|
-
}`;
|
|
747
|
+
const routeRuleTypes = `seoMeta?: import('${typesPath}').MetaFlatSerializable
|
|
748
|
+
head?: import('${typesPath}').Head`;
|
|
749
|
+
const nitroTypes = renderNitroTypeAugmentations(nitroCompatibility, {
|
|
750
|
+
routeRules: routeRuleTypes,
|
|
751
|
+
routeConfig: routeRuleTypes
|
|
752
|
+
});
|
|
683
753
|
return `// Generated by nuxt-seo-utils
|
|
684
754
|
|
|
685
755
|
import type { RobotsContext } from '#robots/types'
|
|
686
756
|
|
|
687
|
-
|
|
688
|
-
${types}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
declare module 'nitropack' {
|
|
692
|
-
${types}
|
|
693
|
-
}
|
|
757
|
+
${nitroTypes}
|
|
694
758
|
|
|
695
759
|
|
|
696
760
|
declare module '@nuxt/schema' {
|
|
@@ -709,6 +773,18 @@ export {}
|
|
|
709
773
|
nuxt: true
|
|
710
774
|
});
|
|
711
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
|
+
});
|
|
712
788
|
nuxt.options.unhead = defu(
|
|
713
789
|
nuxt.options.unhead,
|
|
714
790
|
{ vite: config.treeShakeUseSeoMeta ? { validate: !nuxt.options.test } : false }
|
|
@@ -722,9 +798,12 @@ export {}
|
|
|
722
798
|
const lightningcssPath = resolveModulePath("lightningcss", { try: true, from: importPaths });
|
|
723
799
|
const rolldownURL = rolldownPath ? pathToFileURL(rolldownPath).href : void 0;
|
|
724
800
|
const lightningcssURL = lightningcssPath ? pathToFileURL(lightningcssPath).href : void 0;
|
|
725
|
-
const
|
|
726
|
-
const
|
|
727
|
-
|
|
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,14 @@ export {}
|
|
|
739
818
|
}).code).trim();
|
|
740
819
|
} : void 0
|
|
741
820
|
};
|
|
742
|
-
if (vitePluginSource._tag === "
|
|
743
|
-
logger.warn("`treeShakeUseSeoMeta` requires
|
|
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`.");
|
|
744
825
|
} else {
|
|
745
826
|
addVitePlugin(async () => {
|
|
746
|
-
if (vitePluginSource._tag === "vue") {
|
|
747
|
-
const v3Vite = "@unhead/vue/vite";
|
|
748
|
-
const { Unhead: Unhead2 } = await import(v3Vite);
|
|
749
|
-
return Unhead2({ minify, ...viteOpts });
|
|
750
|
-
}
|
|
751
827
|
const { Unhead } = await import(vitePluginSource.url);
|
|
752
|
-
return Unhead({
|
|
753
|
-
_framework: "@unhead/vue",
|
|
754
|
-
minify,
|
|
755
|
-
validate: false,
|
|
756
|
-
devtools: false
|
|
757
|
-
});
|
|
828
|
+
return Unhead({ minify, ...viteOpts });
|
|
758
829
|
}, {
|
|
759
830
|
prepend: true
|
|
760
831
|
});
|
|
@@ -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
|
-
|
|
124
|
-
|
|
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,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 {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
useHead(headInput);
|
|
25
23
|
if (seoMeta)
|
|
26
24
|
useSeoMeta(seoMeta, { tagPriority });
|
|
27
25
|
}
|
|
@@ -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,2 +1,2 @@
|
|
|
1
|
-
declare const _default:
|
|
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:
|
|
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:
|
|
1
|
+
declare const _default: any;
|
|
2
2
|
export default _default;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineNitroPlugin } from "
|
|
1
|
+
import { defineNitroPlugin } from "#nuxtseo/nitro";
|
|
2
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;
|
|
@@ -1,18 +1,2 @@
|
|
|
1
|
-
declare const _default:
|
|
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 | Record<string, any> | import("ufo").QueryValue[];
|
|
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 "
|
|
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);
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -37,18 +37,14 @@
|
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@unhead/bundler": "^3.2.1",
|
|
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
|
},
|
|
48
47
|
"peerDependenciesMeta": {
|
|
49
|
-
"@unhead/bundler": {
|
|
50
|
-
"optional": true
|
|
51
|
-
},
|
|
52
48
|
"@unhead/vue": {
|
|
53
49
|
"optional": true
|
|
54
50
|
},
|
|
@@ -73,8 +69,8 @@
|
|
|
73
69
|
"escape-string-regexp": "^5.0.0",
|
|
74
70
|
"exsolve": "^1.1.1",
|
|
75
71
|
"image-size": "^2.0.2",
|
|
76
|
-
"nuxt-site-config": "^4.1.
|
|
77
|
-
"nuxtseo-shared": "^5.3.
|
|
72
|
+
"nuxt-site-config": "^4.1.4",
|
|
73
|
+
"nuxtseo-shared": "^5.3.11",
|
|
78
74
|
"pathe": "^2.0.3",
|
|
79
75
|
"pkg-types": "^2.3.1",
|
|
80
76
|
"scule": "^1.3.0",
|
|
@@ -93,7 +89,6 @@
|
|
|
93
89
|
"@nuxt/ui": "^4.10.0",
|
|
94
90
|
"@nuxtjs/i18n": "^10.5.0",
|
|
95
91
|
"@types/node": "^26.1.2",
|
|
96
|
-
"@unhead/bundler": "^3.2.3",
|
|
97
92
|
"@unhead/vue": "3.2.3",
|
|
98
93
|
"@vue/test-utils": "^2.4.11",
|
|
99
94
|
"bumpp": "^12.0.0",
|
|
@@ -127,9 +122,10 @@
|
|
|
127
122
|
"lint:fix": "eslint . --fix",
|
|
128
123
|
"typecheck": "nuxt typecheck",
|
|
129
124
|
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/i18n && nuxt prepare test/fixtures/csp",
|
|
130
|
-
"test": "vitest",
|
|
125
|
+
"test": "vitest && pnpm test:nuxt5",
|
|
131
126
|
"test:run": "vitest run --exclude '**/\\_\\_runtime\\_\\_/**'",
|
|
132
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",
|
|
133
129
|
"release": "pnpm build && bumpp -x \"npx changelogen --output=CHANGELOG.md\"",
|
|
134
130
|
"lint:docs": "markdownlint ./docs/content && case-police 'docs/content/**/*.md' *.md",
|
|
135
131
|
"lint:docs:fix": "markdownlint ./docs/content --fix && case-police 'docs/content/**/*.md' *.md --fix",
|