diff --git a/client/app.vue b/client/app.vue index 2870c478..057b147d 100644 --- a/client/app.vue +++ b/client/app.vue @@ -286,7 +286,7 @@ const userSources = computed(() => (data.value?.globalSources || []).filter(s =>

{{ sitemap.sitemapName }} diff --git a/client/composables/rpc.ts b/client/composables/rpc.ts index 0964d6e9..88dd4294 100644 --- a/client/composables/rpc.ts +++ b/client/composables/rpc.ts @@ -1,5 +1,5 @@ import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client' -import type { $Fetch } from 'nitropack' +import type { $Fetch } from 'ofetch' import { ref, watchEffect } from 'vue' import type { NuxtDevtoolsClient } from '@nuxt/devtools-kit/types' import { refreshSources } from './state' diff --git a/package.json b/package.json index 759b857c..51db44ce 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "test": "vitest run && pnpm run test:attw", "test:unit": "vitest --project=unit", "test:attw": "attw --pack", - "typecheck": "vue-tsc --noEmit" + "typecheck": "nuxt typecheck" }, "dependencies": { "@nuxt/devtools-kit": "^3.1.1", @@ -93,6 +93,7 @@ } }, "devDependencies": { + "vue-i18n-routing": "^1.2.0", "@arethetypeswrong/cli": "^0.18.2", "@nuxt/content": "^3.9.0", "@nuxt/eslint-config": "^1.11.0", diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 00000000..aca7bba9 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.app.json" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f54f63a9..bc315821 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: vitest: specifier: ^3 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.8.1)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vue-i18n-routing: + specifier: ^1.2.0 + version: 1.2.0(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) vue-tsc: specifier: ^3.1.6 version: 3.1.6(typescript@5.9.3) @@ -1016,6 +1019,10 @@ packages: resolution: {integrity: sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==} engines: {node: '>= 16'} + '@intlify/shared@9.14.5': + resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} + engines: {node: '>= 16'} + '@intlify/unplugin-vue-i18n@11.0.1': resolution: {integrity: sha512-nH5NJdNjy/lO6Ne8LDtZzv4SbpVsMhPE+LbvBDmMeIeJDiino8sOJN2QB3MXzTliYTnqe3aB9Fw5+LJ/XVaXCg==} engines: {node: '>= 20'} @@ -1033,6 +1040,22 @@ packages: resolution: {integrity: sha512-8i3uRdAxCGzuHwfmHcVjeLQBtysQB2aXl/ojoagDut5/gY5lvWCQ2+cnl2TiqE/fXj/D8EhWG/SLKA7qz4a3QA==} engines: {node: '>= 18'} + '@intlify/vue-i18n-bridge@1.1.0': + resolution: {integrity: sha512-yBwGpr70Rc56pjsPdtvNRi/ju0P9h3670EkCOuxAzKKR5OH61uF9LprLUGmph/Uy2TXBO2DKqpnJBFXyXJQKeg==} + engines: {node: '>= 12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue-i18n: ^8.26.1 || >=9.2.0 + vue-i18n-bridge: '>=9.2.0' + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-i18n: + optional: true + vue-i18n-bridge: + optional: true + '@intlify/vue-i18n-extensions@8.0.0': resolution: {integrity: sha512-w0+70CvTmuqbskWfzeYhn0IXxllr6mU+IeM2MU0M+j9OW64jkrvqY+pYFWrUnIIC9bEdij3NICruicwd5EgUuQ==} engines: {node: '>= 18'} @@ -1051,6 +1074,19 @@ packages: vue-i18n: optional: true + '@intlify/vue-router-bridge@1.1.0': + resolution: {integrity: sha512-EX+KndT9VS3muMdZWFmc99D8nUaWTOXr322a8zNf5HnMCbpbogdifWYW8hat+nVE73St/gcDbPz6u5smVUPoQg==} + engines: {node: '>= 12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue-router: ^4.0.0-0 || ^3.0.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-router: + optional: true + '@ioredis/commands@1.4.0': resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} @@ -7253,6 +7289,27 @@ packages: vue-flow-layout@0.2.0: resolution: {integrity: sha512-zKgsWWkXq0xrus7H4Mc+uFs1ESrmdTXlO0YNbR6wMdPaFvosL3fMB8N7uTV308UhGy9UvTrGhIY7mVz9eN+L0Q==} + vue-i18n-routing@1.2.0: + resolution: {integrity: sha512-pn+bIFRMX5BN1BVQJ5rn05dYVnBhU/QnkxhjEJAe9HnYtJhDubetvoY+yfgDNWwesNWfHbbvsilsgSGL6DJyeA==} + engines: {node: '>= 14.6'} + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^2.6.14 || ^2.7.0 || ^3.2.0 + vue-i18n: ^8.26.1 || >=9.2.0 + vue-i18n-bridge: '>=9.2.0' + vue-router: ^3.5.3 || ^3.6.0 || ^4.0.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue: + optional: true + vue-i18n: + optional: true + vue-i18n-bridge: + optional: true + vue-router: + optional: true + vue-i18n@11.1.12: resolution: {integrity: sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==} engines: {node: '>= 16'} @@ -8222,6 +8279,8 @@ snapshots: '@intlify/shared@11.1.12': {} + '@intlify/shared@9.14.5': {} + '@intlify/unplugin-vue-i18n@11.0.1(@vue/compiler-dom@3.5.25)(eslint@9.39.1(jiti@2.6.1))(rollup@4.53.3)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -8248,6 +8307,10 @@ snapshots: '@intlify/utils@0.13.0': {} + '@intlify/vue-i18n-bridge@1.1.0(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))': + optionalDependencies: + vue-i18n: 11.1.12(vue@3.5.25(typescript@5.9.3)) + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.12)(@vue/compiler-dom@3.5.25)(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))': dependencies: '@babel/parser': 7.28.4 @@ -8257,6 +8320,14 @@ snapshots: vue: 3.5.25(typescript@5.9.3) vue-i18n: 11.1.12(vue@3.5.25(typescript@5.9.3)) + '@intlify/vue-router-bridge@1.1.0(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.25(typescript@5.9.3)) + optionalDependencies: + vue-router: 4.6.3(vue@3.5.25(typescript@5.9.3)) + transitivePeerDependencies: + - vue + '@ioredis/commands@1.4.0': {} '@isaacs/balanced-match@4.0.1': {} @@ -16306,6 +16377,18 @@ snapshots: vue-flow-layout@0.2.0: {} + vue-i18n-routing@1.2.0(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)): + dependencies: + '@intlify/shared': 9.14.5 + '@intlify/vue-i18n-bridge': 1.1.0(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3))) + '@intlify/vue-router-bridge': 1.1.0(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) + ufo: 1.6.1 + vue-demi: 0.14.10(vue@3.5.25(typescript@5.9.3)) + optionalDependencies: + vue: 3.5.25(typescript@5.9.3) + vue-i18n: 11.1.12(vue@3.5.25(typescript@5.9.3)) + vue-router: 4.6.3(vue@3.5.25(typescript@5.9.3)) + vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)): dependencies: '@intlify/core-base': 11.1.12 diff --git a/src/devtools.ts b/src/devtools.ts index 04c543a8..5c37a872 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -24,8 +24,9 @@ export function setupDevToolsUI(options: ModuleOptions, resolve: Resolver['resol // In local development, start a separate Nuxt Server and proxy to serve the client else { nuxt.hook('vite:extendConfig', (config) => { - config.server = config.server || {} - config.server.proxy = config.server.proxy || {} + // @ts-expect-error vite config server is readonly but we need to mutate it + if (!config.server) config.server = {} + if (!config.server.proxy) config.server.proxy = {} config.server.proxy[DEVTOOLS_UI_ROUTE] = { target: `http://localhost:${DEVTOOLS_UI_LOCAL_PORT}${DEVTOOLS_UI_ROUTE}`, changeOrigin: true, diff --git a/src/prerender.ts b/src/prerender.ts index 95039156..4440f281 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -153,8 +153,9 @@ async function prerenderRoute(nitro: Nitro, route: string) { const _route: PrerenderRoute = { route, fileName: route } // Fetch the route const encodedRoute = encodeURI(route) + const fetchUrl = withBase(encodedRoute, nitro.options.baseURL) const res = await globalThis.$fetch.raw( - withBase(encodedRoute, nitro.options.baseURL), + fetchUrl, { headers: { 'x-nitro-prerender': encodedRoute }, retry: nitro.options.prerender.retry, @@ -171,10 +172,13 @@ async function prerenderRoute(nitro: Nitro, route: string) { const filePath = join(nitro.options.output.publicDir, _route.fileName!) await mkdir(dirname(filePath), { recursive: true }) const data = res._data + if (data === undefined) { + throw new Error(`No data returned from '${fetchUrl}'`) + } if (filePath.endsWith('json') || typeof data === 'object') await writeFile(filePath, JSON.stringify(data), 'utf8') else - await writeFile(filePath, data, 'utf8') + await writeFile(filePath, data as string, 'utf8') _route.generateTimeMS = Date.now() - start nitro._prerenderedRoutes!.push(_route) nitro.logger.log(formatPrerenderRoute(_route)) diff --git a/src/runtime/server/plugins/nuxt-content-v2.ts b/src/runtime/server/plugins/nuxt-content-v2.ts index f9b0ced2..ab2bc437 100644 --- a/src/runtime/server/plugins/nuxt-content-v2.ts +++ b/src/runtime/server/plugins/nuxt-content-v2.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck this is for v2, consider it was stable and the types will not match when running type tests import { defu } from 'defu' import type { ParsedContentv2 } from '@nuxt/content' import type { NitroApp } from 'nitropack/types' diff --git a/src/runtime/server/routes/__sitemap__/debug.ts b/src/runtime/server/routes/__sitemap__/debug.ts index d9e88466..a9e5d09e 100644 --- a/src/runtime/server/routes/__sitemap__/debug.ts +++ b/src/runtime/server/routes/__sitemap__/debug.ts @@ -1,5 +1,4 @@ import { defineEventHandler } from 'h3' -import type { SitemapDefinition } from '../../../types' import { useSitemapRuntimeConfig } from '../../utils' import { childSitemapSources, @@ -17,8 +16,11 @@ export default defineEventHandler(async (e) => { delete runtimeConfig.sitemaps const globalSources = await globalSitemapSources() const nitroOrigin = getNitroOrigin(e) - const sitemaps: Record = {} + const sitemaps: Record> }> = {} for (const s of Object.keys(_sitemaps)) { + if (!_sitemaps[s]) { + throw new Error('Could not resolve matching key in _sitemaps') + } // resolve the sources sitemaps[s] = { ..._sitemaps[s], diff --git a/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v2.ts b/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v2.ts index 6e948065..f7e004ff 100644 --- a/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v2.ts +++ b/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v2.ts @@ -1,4 +1,5 @@ import { defineEventHandler } from 'h3' +// @ts-expect-error for nuxt v2 - type checking for nuxt v3 import type { ParsedContent } from '@nuxt/content' // @ts-expect-error alias module diff --git a/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts b/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts index 41216aea..89800198 100644 --- a/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts +++ b/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts @@ -2,13 +2,14 @@ import { defineEventHandler } from 'h3' import { queryCollection } from '@nuxt/content/server' // @ts-expect-error alias import manifest from '#content/manifest' +import type { Collections } from '@nuxt/content' export default defineEventHandler(async (e) => { - const collections = [] + const collections: (keyof Collections)[] = [] // each collection in the manifest has a key => with fields which has a `sitemap`, we want to get all those for (const collection in manifest) { - if (manifest[collection].fields.sitemap) { - collections.push(collection) + if (manifest[collection]?.fields?.sitemap) { + collections.push(collection as keyof Collections) } } // now we need to handle multiple queries here, we want to run the requests in parralel @@ -31,6 +32,7 @@ export default defineEventHandler(async (e) => { .filter(c => c.sitemap !== false && c.path) .flatMap(c => ({ loc: c.path, + // @ts-expect-error cannot figure out how to make sure this is resolvable when no Collections are in manifest ...(c.sitemap || {}), })) }) diff --git a/src/runtime/server/routes/sitemap.xml.ts b/src/runtime/server/routes/sitemap.xml.ts index 217cf566..376ec2ba 100644 --- a/src/runtime/server/routes/sitemap.xml.ts +++ b/src/runtime/server/routes/sitemap.xml.ts @@ -13,5 +13,9 @@ export default defineEventHandler(async (e) => { return sendRedirect(e, withBase('/sitemap_index.xml', useRuntimeConfig().app.baseURL), import.meta.dev ? 302 : 301) } - return createSitemap(e, Object.values(sitemaps)[0], runtimeConfig) + const sitemap = Object.values(sitemaps) + // if we had an index, we would have returned above. as we do not + // this is compatible with SitemapDefinition expected + const sm = sitemap[0] as typeof sitemaps['any_key_except_index'] + return createSitemap(e, sm, runtimeConfig) }) diff --git a/src/runtime/server/routes/sitemap/[sitemap].xml.ts b/src/runtime/server/routes/sitemap/[sitemap].xml.ts index f255994c..3253bf6b 100644 --- a/src/runtime/server/routes/sitemap/[sitemap].xml.ts +++ b/src/runtime/server/routes/sitemap/[sitemap].xml.ts @@ -66,7 +66,7 @@ export default defineEventHandler(async (e) => { } // Get the appropriate sitemap configuration - const sitemapConfig = getSitemapConfig(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize) + const sitemapConfig = getSitemapConfig(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize || 1000) return createSitemap(e, sitemapConfig, runtimeConfig) }) diff --git a/src/runtime/server/sitemap/builder/sitemap.ts b/src/runtime/server/sitemap/builder/sitemap.ts index 1fe53493..a69e5179 100644 --- a/src/runtime/server/sitemap/builder/sitemap.ts +++ b/src/runtime/server/sitemap/builder/sitemap.ts @@ -70,10 +70,11 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU for (const e of validI18nUrlsForTransform) { // let's try and find other urls that we can use for alternatives if (!e._i18nTransform && !e.alternatives?.length) { - const alternatives = withoutPrefixPaths[e._pathWithoutPrefix] - .map((u) => { + function processAlternatives(alts: undefined | NormalizedI18n[]) { + if (!alts) return [] + return alts?.map((u) => { const entries: AlternativeEntry[] = [] - if (u._locale.code === autoI18n.defaultLocale) { + if (u._locale.code === autoI18n?.defaultLocale) { entries.push({ href: u.loc, hreflang: 'x-default', @@ -81,35 +82,41 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU } entries.push({ href: u.loc, - hreflang: u._locale._hreflang || autoI18n.defaultLocale, + hreflang: u._locale._hreflang || autoI18n?.defaultLocale, }) return entries }) - .flat() - .filter(Boolean) as AlternativeEntry[] + .flat() + } + const alternatives = processAlternatives(withoutPrefixPaths[e._pathWithoutPrefix]) + if (alternatives.length) e.alternatives = alternatives } else if (e._i18nTransform) { delete e._i18nTransform + // @ts-expect-error looks to be checking an old possible value for strategy, no longer typed as valid if (autoI18n.strategy === 'no_prefix') { warnIncorrectI18nTransformUsage = true } - // keep single entry, just add alternatvies + // keep single entry, just add alternatives if (autoI18n.differentDomains) { - e.alternatives = [ - { + const alternatives: AutoI18nConfig['locales'] = [] + const defaultLocale = autoI18n.locales.find(l => [l.code, l.language].includes(autoI18n.defaultLocale)) + if (defaultLocale) { + alternatives.push({ // apply default locale domain - ...autoI18n.locales.find(l => [l.code, l.language].includes(autoI18n.defaultLocale)), + ...defaultLocale, code: 'x-default', - }, - ...autoI18n.locales - .filter(l => !!l.domain), - ] + }) + } + const localesForDomain = autoI18n.locales.filter(l => !!l.domain) + alternatives.push(...localesForDomain) + e.alternatives = alternatives .map((locale) => { return { - hreflang: locale._hreflang, - href: joinURL(withHttps(locale.domain!), e._pathWithoutPrefix), + hreflang: locale.hreflang, + href: joinURL(withHttps(String(locale.href)!), e._pathWithoutPrefix), } }) } @@ -145,14 +152,12 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU } const _sitemap = isI18nMapped ? l._sitemap : undefined - const newEntry: NormalizedI18n = preNormalizeEntry({ - _sitemap, - ...e, - _index: undefined, - _key: `${_sitemap || ''}${loc || '/'}${e._path.search}`, - _locale: l, - loc, - alternatives: [{ code: 'x-default', _hreflang: 'x-default' }, ...autoI18n.locales].map((locale) => { + + const alternatives: AlternativeEntry[] = [ + { code: 'x-default', _hreflang: 'x-default' }, + ...autoI18n.locales, + ] + .map((locale): AlternativeEntry | false => { const code = locale.code === 'x-default' ? autoI18n.defaultLocale : locale.code const isDefault = locale.code === 'x-default' || locale.code === autoI18n.defaultLocale let href = e._pathWithoutPrefix @@ -192,13 +197,24 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU if (!filterPath(href)) return false + return { hreflang: locale._hreflang, href, } - }).filter(Boolean), + }) + .filter(l => l !== false) + + const newEntry: ResolvedSitemapUrl = preNormalizeEntry({ + _sitemap, + ...e, + _index: undefined, + _locale: l, + loc, + alternatives, }, resolvers) - if (e._locale.code === newEntry._locale.code) { + + if (e._index !== undefined && e._locale.code === newEntry._locale?.code) { // replace _urls[e._index] = newEntry // avoid getting re-replaced @@ -253,7 +269,7 @@ export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: Ni } function maybeSlice(urls: T): T { - return sliceUrlsForChunk(urls, sitemap.sitemapName, sitemaps, defaultSitemapsChunkSize) as T + return sliceUrlsForChunk(urls, sitemap.sitemapName, sitemaps, defaultSitemapsChunkSize !== false ? defaultSitemapsChunkSize : undefined) as T } if (autoI18n?.differentDomains) { const domain = autoI18n.locales.find(e => [e.language, e.code].includes(sitemap.sitemapName))?.domain diff --git a/src/runtime/server/sitemap/builder/xml.ts b/src/runtime/server/sitemap/builder/xml.ts index 905c8753..8d462ee4 100644 --- a/src/runtime/server/sitemap/builder/xml.ts +++ b/src/runtime/server/sitemap/builder/xml.ts @@ -1,5 +1,13 @@ import { withQuery } from 'ufo' -import type { ModuleRuntimeConfig, NitroUrlResolvers, ResolvedSitemapUrl } from '../../../types' +import type { + AlternativeEntry, + GoogleNewsEntry, + ImageEntry, + ModuleRuntimeConfig, + NitroUrlResolvers, + ResolvedSitemapUrl, + VideoEntry, +} from '../../../types' import { xmlEscape } from '../../utils' // Optimized XML escaping using string replace (faster than character loop) @@ -52,7 +60,7 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { switch (key) { case 'alternatives': if (Array.isArray(value) && value.length > 0) { - for (const alt of value) { + for (const alt of value as AlternativeEntry[]) { const attrs = Object.entries(alt) .map(([k, v]) => `${k}="${escapeValueForXml(v)}"`) .join(' ') @@ -63,13 +71,13 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { case 'images': if (Array.isArray(value) && value.length > 0) { - for (const img of value) { + for (const img of value as ImageEntry[]) { parts[partIndex++] = ' ' - parts[partIndex++] = ` ${escapeValueForXml(img.loc)}` + parts[partIndex++] = ` ${escapeValueForXml(String(img.loc))}` if (img.title) parts[partIndex++] = ` ${escapeValueForXml(img.title)}` if (img.caption) parts[partIndex++] = ` ${escapeValueForXml(img.caption)}` - if (img.geo_location) parts[partIndex++] = ` ${escapeValueForXml(img.geo_location)}` - if (img.license) parts[partIndex++] = ` ${escapeValueForXml(img.license)}` + if (img.geoLocation) parts[partIndex++] = ` ${escapeValueForXml(img.geoLocation)}` + if (img.license) parts[partIndex++] = ` ${escapeValueForXml(String(img.license))}` parts[partIndex++] = ' ' } } @@ -77,22 +85,17 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { case 'videos': if (Array.isArray(value) && value.length > 0) { - for (const video of value) { + for (const video of value as VideoEntry[]) { parts[partIndex++] = ' ' parts[partIndex++] = ` ${escapeValueForXml(video.title)}` if (video.thumbnail_loc) { - parts[partIndex++] = ` ${escapeValueForXml(video.thumbnail_loc)}` + parts[partIndex++] = ` ${escapeValueForXml(String(video.thumbnail_loc))}` } parts[partIndex++] = ` ${escapeValueForXml(video.description)}` if (video.content_loc) { - parts[partIndex++] = ` ${escapeValueForXml(video.content_loc)}` - } - if (video.player_loc) { - const attrs = video.player_loc.allow_embed ? ' allow_embed="yes"' : '' - const autoplay = video.player_loc.autoplay ? ' autoplay="yes"' : '' - parts[partIndex++] = ` ${escapeValueForXml(video.player_loc)}` + parts[partIndex++] = ` ${escapeValueForXml(String(video.content_loc))}` } if (video.duration !== undefined) { parts[partIndex++] = ` ${video.duration}` @@ -126,6 +129,7 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { if (video.price) { const prices = Array.isArray(video.price) ? video.price : [video.price] for (const price of prices) { + if (!price.price) continue const attrs: string[] = [] if (price.currency) attrs.push(`currency="${price.currency}"`) if (price.type) attrs.push(`type="${price.type}"`) @@ -134,7 +138,7 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { } } if (video.uploader) { - const info = video.uploader.info ? ` info="${escapeValueForXml(video.uploader.info)}"` : '' + const info = video.uploader.info ? ` info="${escapeValueForXml(String(video.uploader.info))}"` : '' parts[partIndex++] = ` ${escapeValueForXml(video.uploader.uploader)}` } if (video.live !== undefined) { @@ -150,8 +154,7 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { parts[partIndex++] = ` ${escapeValueForXml(video.category)}` } if (video.gallery_loc) { - const title = video.gallery_loc.title ? ` title="${escapeValueForXml(video.gallery_loc.title)}"` : '' - parts[partIndex++] = ` ${escapeValueForXml(video.gallery_loc)}` + parts[partIndex++] = ` ${escapeValueForXml(String(video.gallery_loc))}` } parts[partIndex++] = ' ' } @@ -160,29 +163,18 @@ function buildUrlXml(url: ResolvedSitemapUrl): string { case 'news': if (value) { + const newsValue = value as GoogleNewsEntry parts[partIndex++] = ' ' parts[partIndex++] = ' ' - parts[partIndex++] = ` ${escapeValueForXml(value.publication.name)}` - parts[partIndex++] = ` ${escapeValueForXml(value.publication.language)}` + parts[partIndex++] = ` ${escapeValueForXml(newsValue.publication.name)}` + parts[partIndex++] = ` ${escapeValueForXml(newsValue.publication.language)}` parts[partIndex++] = ' ' - if (value.title) { - parts[partIndex++] = ` ${escapeValueForXml(value.title)}` - } - if (value.publication_date) { - parts[partIndex++] = ` ${value.publication_date}` - } - if (value.access) { - parts[partIndex++] = ` ${value.access}` - } - if (value.genres) { - parts[partIndex++] = ` ${escapeValueForXml(value.genres)}` - } - if (value.keywords) { - parts[partIndex++] = ` ${escapeValueForXml(value.keywords)}` + if (newsValue.title) { + parts[partIndex++] = ` ${escapeValueForXml(newsValue.title)}` } - if (value.stock_tickers) { - parts[partIndex++] = ` ${escapeValueForXml(value.stock_tickers)}` + if (newsValue.publication_date) { + parts[partIndex++] = ` ${newsValue.publication_date}` } parts[partIndex++] = ' ' } diff --git a/src/runtime/server/sitemap/nitro.ts b/src/runtime/server/sitemap/nitro.ts index e00fe033..56841ce3 100644 --- a/src/runtime/server/sitemap/nitro.ts +++ b/src/runtime/server/sitemap/nitro.ts @@ -17,13 +17,13 @@ import { normaliseEntry, preNormalizeEntry } from './urlset/normalise' import { sortInPlace } from './urlset/sort' // @ts-expect-error virtual import { getPathRobotConfig } from '#internal/nuxt-robots/getPathRobotConfig' // can't solve this -import { useSiteConfig } from '#site-config/server/composables/useSiteConfig' import { createSitePathResolver } from '#site-config/server/composables/utils' +import { getSiteConfig } from '#site-config/server/composables' export function useNitroUrlResolvers(e: H3Event): NitroUrlResolvers { const canonicalQuery = getQuery(e).canonical const isShowingCanonical = typeof canonicalQuery !== 'undefined' && canonicalQuery !== 'false' - const siteConfig = useSiteConfig(e) + const siteConfig = getSiteConfig(e) return { event: e, fixSlashes: (path: string) => fixSlashes(siteConfig.trailingSlash, path), @@ -42,12 +42,12 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re const { sitemapName } = definition const nitro = useNitroApp() if (import.meta.prerender) { - const config = useSiteConfig(event) + const config = getSiteConfig(event) if (!config.url && !nitro._sitemapWarned) { nitro._sitemapWarned = true logger.error('Sitemap Site URL missing!') logger.info('To fix this please add `{ site: { url: \'site.com\' } }` to your Nuxt config or a `NUXT_PUBLIC_SITE_URL=site.com` to your .env. Learn more at https://nuxtseo.com/site-config/getting-started/how-it-works') - throw new createError({ + throw new (createError as any)({ statusMessage: 'You must provide a site URL to prerender a sitemap.', statusCode: 500, }) @@ -60,8 +60,7 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re // Process in place to avoid creating intermediate arrays let validCount = 0 - for (let i = 0; i < sitemapUrls.length; i++) { - const u = sitemapUrls[i] + for (const u of sitemapUrls) { const path = u._path?.pathname || u.loc // Early continue for robots blocked paths @@ -81,7 +80,7 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re // Skip invalid entries if (routeRules.sitemap === false) continue - // @ts-expect-error runtime types + if (typeof routeRules.robots !== 'undefined' && !routeRules.robots) continue diff --git a/src/runtime/server/sitemap/urlset/normalise.ts b/src/runtime/server/sitemap/urlset/normalise.ts index f8e1fda1..7997c358 100644 --- a/src/runtime/server/sitemap/urlset/normalise.ts +++ b/src/runtime/server/sitemap/urlset/normalise.ts @@ -17,7 +17,7 @@ import type { import { mergeOnKey } from '../../../utils-pure' function resolve(s: string | URL, resolvers?: NitroUrlResolvers): string -function resolve(s: string | undefined | URL, resolvers?: NitroUrlResolvers): string | undefined { +function resolve(s: string | undefined | URL, resolvers?: NitroUrlResolvers): string | URL | undefined { if (typeof s === 'undefined' || !resolvers) return s // convert url to string @@ -37,8 +37,11 @@ function removeTrailingSlash(s: string) { export function preNormalizeEntry(_e: SitemapUrl | string, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl { const e = (typeof _e === 'string' ? { loc: _e } : { ..._e }) as ResolvedSitemapUrl + // @ts-expect-error detecting older property, url, to update it if (e.url && !e.loc) { + // @ts-expect-error moving old url property to new loc e.loc = e.url + // @ts-expect-error url would exist here but still not typed delete e.url } if (typeof e.loc !== 'string') { @@ -50,7 +53,7 @@ export function preNormalizeEntry(_e: SitemapUrl | string, resolvers?: NitroUrlR try { e._path = e._abs ? parseURL(e.loc) : parsePath(e.loc) } - catch (e) { + catch (e: any) { e._path = null } if (e._path) { @@ -104,8 +107,7 @@ export function normaliseEntry(_e: ResolvedSitemapUrl, defaults: Omit ({ ...a })) - for (let i = 0; i < alternatives.length; i++) { - const alt = alternatives[i] + for (const alt of alternatives) { // Modify in place if (typeof alt.href === 'string') { alt.href = resolve(alt.href, resolvers) @@ -120,20 +122,23 @@ export function normaliseEntry(_e: ResolvedSitemapUrl, defaults: Omit ({ ...i })) - for (let i = 0; i < images.length; i++) { - images[i].loc = resolve(images[i].loc, resolvers) - } + images.forEach((image, i) => { + if (!images[i]) return + images[i].loc = resolve(image.loc, resolvers) + }) e.images = mergeOnKey(images, 'loc') } if (e.videos) { // Process videos in place const videos = e.videos.map(v => ({ ...v })) - for (let i = 0; i < videos.length; i++) { - if (videos[i].content_loc) { - videos[i].content_loc = resolve(videos[i].content_loc, resolvers) + videos.forEach((v, i) => { + const contentLoc = v.content_loc + // current ts says videos[i] can be undefined. It cannot, but check added + if (contentLoc && videos[i]) { + videos[i].content_loc = resolve(contentLoc, resolvers) } - } + }) e.videos = mergeOnKey(videos, 'content_loc') } return e @@ -156,7 +161,7 @@ export function normaliseDate(d: Date | string) { // correct a time component without a timezone if (d.includes('T')) { const t = d.split('T')[1] - if (!t.includes('+') && !t.includes('-') && !t.includes('Z')) { + if (!t || (t && !t.includes('+') && !t.includes('-') && !t.includes('Z'))) { // add UTC timezone d += 'Z' } diff --git a/src/runtime/server/sitemap/utils/chunk.ts b/src/runtime/server/sitemap/utils/chunk.ts index 9e1bbb47..9cac7cb3 100644 --- a/src/runtime/server/sitemap/utils/chunk.ts +++ b/src/runtime/server/sitemap/utils/chunk.ts @@ -86,6 +86,10 @@ export function getSitemapConfig( } } + if (!sitemaps[sitemapName]) { + throw new Error(`Cannot find sitemap with name: '${sitemapName}'`) + } + // Regular sitemap return sitemaps[sitemapName] } diff --git a/src/runtime/server/utils.ts b/src/runtime/server/utils.ts index e0e6729a..44f2553e 100644 --- a/src/runtime/server/utils.ts +++ b/src/runtime/server/utils.ts @@ -21,6 +21,7 @@ export function useSitemapRuntimeConfig(e?: H3Event): ModuleRuntimeConfig { // normalize the filters for runtime for (const k in clone.sitemaps) { const sitemap = clone.sitemaps[k] + if (!sitemap) continue sitemap.include = normalizeRuntimeFilters(sitemap.include) sitemap.exclude = normalizeRuntimeFilters(sitemap.exclude) clone.sitemaps[k] = sitemap diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ea40d5aa..958ded6c 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -2,6 +2,23 @@ import type { FetchOptions } from 'ofetch' import type { H3Event } from 'h3' import type { ParsedURL } from 'ufo' import type { NuxtI18nOptions } from '@nuxtjs/i18n' +import type { MaybeArray } from 'unhead/types' + +declare module 'nitropack/types' { + interface NitroApp { + _sitemapWarned?: boolean + } + + interface NitroRouteConfig { + robots?: boolean + } +} + +declare module 'nitropack' { + interface NitroRouteRules { + robots?: boolean + } +} // we need to have the module options within the runtime entry // as we don't want to depend on the module entry as it can cause @@ -208,6 +225,8 @@ interface LocaleObject extends Record { cache?: boolean }[] isCatchallLocale?: boolean + _sitemap?: string + _hreflang?: string /** * @deprecated in v9, use `language` instead */ @@ -217,7 +236,7 @@ interface LocaleObject extends Record { export interface AutoI18nConfig { differentDomains?: boolean - locales: (LocaleObject & { _sitemap: string, _hreflang: string })[] + locales: LocaleObject[] defaultLocale: string strategy: 'prefix' | 'prefix_except_default' | 'prefix_and_default' | 'no_prefix' pages?: Record> @@ -226,7 +245,13 @@ export interface AutoI18nConfig { export interface ModuleRuntimeConfig extends Pick { version: string isNuxtContentDocumentDriven: boolean - sitemaps: { index?: Pick & { sitemaps: SitemapIndexEntry[] } } & Record & { _hasSourceChunk?: boolean }> + sitemaps: { + index?: Pick & { sitemaps: SitemapIndexEntry[] } + } + & Record< + string, + Omit & { _hasSourceChunk?: boolean } + > autoI18n?: AutoI18nConfig isMultiSitemap: boolean isI18nMapped: boolean @@ -294,7 +319,7 @@ export interface SitemapDefinition { /** * Default options for all URLs in the sitemap. */ - defaults?: Omit + defaults: Omit /** * Additional sources of URLs to include in the sitemap. */ @@ -392,12 +417,19 @@ export interface SitemapUrl { videos?: Array _i18nTransform?: boolean _sitemap?: string | false + /** + * Added these for sitemap.ts on or around line 199 + * const newEntry: ResolvedSitemapUrl = preNormalizeEntry({}) + */ + _index?: number + _key?: string + _locale?: LocaleObject } export type SitemapStrict = Required export interface AlternativeEntry { - hreflang: string + hreflang?: string href: string | URL } @@ -436,6 +468,12 @@ export interface ImageEntry { license?: string | URL } +export interface VideoEntryPrice { + price?: number | string + currency?: string + type?: 'rent' | 'purchase' | 'package' | 'subscription' +} + export interface VideoEntry { title: string thumbnail_loc: string | URL @@ -450,11 +488,7 @@ export interface VideoEntry { family_friendly?: 'yes' | 'no' | boolean restriction?: Restriction platform?: Platform - price?: ({ - price?: number | string - currency?: string - type?: 'rent' | 'purchase' | 'package' | 'subscription' - })[] + price?: MaybeArray requires_subscription?: 'yes' | 'no' | boolean uploader?: { uploader: string @@ -462,6 +496,8 @@ export interface VideoEntry { } live?: 'yes' | 'no' | boolean tag?: string | string[] + category?: string + gallery_loc?: string | URL } export interface Restriction { diff --git a/src/runtime/utils-pure.ts b/src/runtime/utils-pure.ts index ae93423d..bc834a8a 100644 --- a/src/runtime/utils-pure.ts +++ b/src/runtime/utils-pure.ts @@ -46,7 +46,7 @@ export function splitForLocales(path: string, locales: string[]): [string | null // we only want to use the first path segment otherwise we can end up turning "/ending" into "/en/ding" const prefix = withLeadingSlash(path).split('/')[1] // make sure prefix is a valid locale - if (locales.includes(prefix)) + if (prefix && locales.includes(prefix)) return [prefix, path.replace(`/${prefix}`, '')] return [null, path] } @@ -62,7 +62,7 @@ export function normalizeRuntimeFilters(input?: FilterInput[]): (RegExp | string return rule // regex is already validated const match = rule.regex.match(StringifiedRegExpPattern) - if (match) + if (match && match[1] && match[2]) return new RegExp(match[1], match[2]) return false }).filter(Boolean) as (RegExp | string)[] diff --git a/src/utils-internal/i18n.ts b/src/utils-internal/i18n.ts index 37a7d179..db016488 100644 --- a/src/utils-internal/i18n.ts +++ b/src/utils-internal/i18n.ts @@ -44,10 +44,10 @@ export function generatePathForI18nPages(ctx: StrategyProps): string { } export function normalizeLocales(nuxtI18nConfig: NuxtI18nOptions): AutoI18nConfig['locales'] { - let locales = nuxtI18nConfig.locales || [] + const _locales: NonNullable = nuxtI18nConfig.locales || [] let onlyLocales = nuxtI18nConfig?.bundle?.onlyLocales || [] onlyLocales = typeof onlyLocales === 'string' ? [onlyLocales] : onlyLocales - locales = mergeOnKey(locales.map((locale: any) => typeof locale === 'string' ? { code: locale } : locale), 'code') + let locales: AutoI18nConfig['locales'] = mergeOnKey(_locales.map((locale: any) => typeof locale === 'string' ? { code: locale } : locale), 'code') if (onlyLocales.length) { locales = locales.filter((locale: LocaleObject) => onlyLocales.includes(locale.code)) } diff --git a/src/utils-internal/nuxtSitemap.ts b/src/utils-internal/nuxtSitemap.ts index 3dd5032d..6d44c57a 100644 --- a/src/utils-internal/nuxtSitemap.ts +++ b/src/utils-internal/nuxtSitemap.ts @@ -10,12 +10,12 @@ import type { AutoI18nConfig, SitemapDefinition, SitemapUrl, SitemapUrlInput } f import { createPathFilter } from '../runtime/utils-pure' import type { CreateFilterOptions } from '../runtime/utils-pure' -export async function resolveUrls(urls: Required['urls'], ctx: { logger: ConsolaInstance, path: string }): Promise { +export async function resolveUrls(_urls: Required['urls'], ctx: { logger: ConsolaInstance, path: string }): Promise { + let urls: SitemapUrlInput[] = [] try { - if (typeof urls === 'function') - urls = urls() - // resolve promise - urls = await urls + if (typeof _urls === 'function') { + urls = await _urls() + } } catch (e) { ctx.logger.error(`Failed to resolve ${typeof urls} urls.`) @@ -71,7 +71,7 @@ function deepForEachPage( if (opts.isI18nMicro) { const localePattern = /\/:locale\(([^)]+)\)/ const match = localePattern.exec(currentPath || '') - if (match) { + if (match && match[1]) { const locales = match[1].split('|') locales.forEach((locale) => { const subPage = { ...page } @@ -151,6 +151,9 @@ export function convertNuxtPagesToSitemapEntries(pages: NuxtPage[], config: Nuxt pagesWithMeta.reduce((acc: Record, e) => { if (e.page!.name?.includes(routesNameSeparator)) { const [name, locale] = e.page!.name.split(routesNameSeparator) + if (!name) { + return acc + } if (!acc[name]) acc[name] = [] const { _sitemap } = config.normalisedLocales.find(l => l.code === locale) || { _sitemap: locale } @@ -170,6 +173,7 @@ export function convertNuxtPagesToSitemapEntries(pages: NuxtPage[], config: Nuxt // we add pages without a prefix, they may have disabled i18n return entries.map((e) => { const [name] = (e.page?.name || '').split(routesNameSeparator) + if (!name) return false // we need to check if the same page with a prefix exists within the default locale // for example this will fix the `/` if the configuration is set to `prefix` if (localeGroups[name]?.some(a => a.locale === config.defaultLocale)) @@ -222,7 +226,7 @@ export function convertNuxtPagesToSitemapEntries(pages: NuxtPage[], config: Nuxt } export function generateExtraRoutesFromNuxtConfig(nuxt: Nuxt = useNuxt()) { - const filterForValidPage = p => p && !extname(p) && !p.startsWith('/api/') && !p.startsWith('/_') + const filterForValidPage = (p: string | undefined) => p && !extname(p) && !p.startsWith('/api/') && !p.startsWith('/_') const routeRules = Object.entries(nuxt.options.routeRules || {}) .filter(([k, v]) => { // make sure key doesn't use a wildcard and its not for a file diff --git a/src/utils/parseSitemapXml.ts b/src/utils/parseSitemapXml.ts index 9e1f49c1..4d47801d 100644 --- a/src/utils/parseSitemapXml.ts +++ b/src/utils/parseSitemapXml.ts @@ -1,4 +1,12 @@ -import type { SitemapUrlInput, VideoEntry, ImageEntry, AlternativeEntry, GoogleNewsEntry, SitemapStrict } from '../runtime/types' +import type { + SitemapUrlInput, + VideoEntry, + ImageEntry, + AlternativeEntry, + GoogleNewsEntry, + SitemapStrict, + VideoEntryPrice +} from '../runtime/types' interface ParsedUrl { loc?: string @@ -384,7 +392,7 @@ function extractUrlFromParsedElement( return { price: String(priceValue), currency: price.currency, - type: price.type as VideoEntry['price'][0]['type'], + type: price.type as VideoEntryPrice['type'], } }) .filter((p): p is NonNullable => p !== null) @@ -487,7 +495,7 @@ function extractUrlFromParsedElement( } // Filter out undefined values and empty arrays - const filteredUrlObj = Object.fromEntries( + const filteredUrlObj: typeof urlObj = Object.fromEntries( Object.entries(urlObj).filter(([_, value]) => value != null && (!Array.isArray(value) || value.length > 0), ), diff --git a/tsconfig.json b/tsconfig.json index c5c22aab..00bff21f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./.nuxt/tsconfig.json", "exclude": [ "test/**", - "playground" + "playground", + "dist/" ] }