nuxt-seo-utils 7.0.19 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +8 -1
  2. package/bin/nuxt-seo-utils.mjs +2 -0
  3. package/dist/cli.d.mts +2 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.mjs +118 -0
  6. package/dist/module.d.mts +25 -0
  7. package/dist/module.d.ts +140 -0
  8. package/dist/module.json +1 -1
  9. package/dist/module.mjs +229 -18
  10. package/dist/runtime/app/composables/polyfills.d.ts +4 -3
  11. package/dist/runtime/app/composables/polyfills.js +1 -2
  12. package/dist/runtime/app/composables/useBreadcrumbItems.d.ts +2 -2
  13. package/dist/runtime/app/composables/useBreadcrumbItems.js +7 -7
  14. package/dist/runtime/app/composables/useFallbackTitle.d.ts +1 -0
  15. package/dist/runtime/app/composables/useFallbackTitle.js +31 -0
  16. package/dist/runtime/app/composables/useShareLinks.d.ts +78 -0
  17. package/dist/runtime/app/composables/useShareLinks.js +94 -0
  18. package/dist/runtime/app/logic/applyDefaults.js +10 -6
  19. package/dist/runtime/app/plugins/0.routeRules.js +10 -5
  20. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.js +1 -1
  21. package/dist/runtime/app/plugins/inferSeoMetaPlugin.js +1 -1
  22. package/dist/runtime/app/plugins/minifyScripts.server.d.ts +2 -0
  23. package/dist/runtime/app/plugins/minifyScripts.server.js +52 -0
  24. package/dist/runtime/app/plugins/siteConfig.js +4 -3
  25. package/dist/runtime/app/plugins/titles.js +6 -17
  26. package/dist/runtime/server/middleware/redirectCanonical.js +2 -2
  27. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +6 -0
  28. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +10 -0
  29. package/dist/runtime/shared/minify.d.ts +14 -0
  30. package/dist/runtime/shared/minify.js +133 -0
  31. package/package.json +75 -40
  32. package/dist/runtime/server/tsconfig.json +0 -3
package/dist/module.mjs CHANGED
@@ -1,15 +1,16 @@
1
- import { useNuxt, addTypeTemplate, defineNuxtModule, useLogger, createResolver, addServerHandler, hasNuxtModule, addPlugin, addImports, addVitePlugin } from '@nuxt/kit';
1
+ import fs, { existsSync } from 'node:fs';
2
+ import { useNuxt, addTypeTemplate, useLogger, defineNuxtModule, createResolver, addServerHandler, hasNuxtModule, addPlugin, addImports, addVitePlugin } from '@nuxt/kit';
2
3
  import UnheadVite from '@unhead/addons/vite';
3
4
  import { defu } from 'defu';
4
5
  import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
5
- import { resolve, relative, extname, dirname, basename } from 'pathe';
6
+ import { resolve, extname, relative, dirname, basename } from 'pathe';
6
7
  import { unpackMeta } from '@unhead/vue/utils';
7
- import fg from 'fast-glob';
8
- import fs from 'node:fs';
8
+ import { glob } from 'tinyglobby';
9
9
  import { encodePath, joinURL } from 'ufo';
10
10
  import escapeRE from 'escape-string-regexp';
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import imageSize from 'image-size';
13
+ import { setupDevToolsUI as setupDevToolsUI$1 } from 'nuxtseo-shared/devtools';
13
14
 
14
15
  function extendNuxtConfigAppHeadSeoMeta(nuxt = useNuxt()) {
15
16
  const seoMeta = nuxt.options.app?.seoMeta || nuxt.options.app?.head?.seoMeta || {};
@@ -34,7 +35,7 @@ function extendNuxtConfigAppHeadTypes(nuxt = useNuxt()) {
34
35
  addTypeTemplate({
35
36
  filename: "module/nuxt-seo-utils.assets.d.ts",
36
37
  getContents: async () => {
37
- const paths = (await fg(["**/*"], { cwd: resolve(nuxt.options.srcDir, "public") })).map((p) => `/${p}`);
38
+ const paths = (await glob(["**/*"], { cwd: resolve(nuxt.options.srcDir, "public") })).map((p) => `/${p}`);
38
39
  const jsPaths = paths.filter((p) => p.endsWith(".js") || p.endsWith(".mjs"));
39
40
  return `// Generated by nuxt-seo-utils
40
41
  declare module '#app' {
@@ -81,8 +82,10 @@ const MetaTagFileGlobs = [
81
82
  "**/*icon-*.{ico,jpg,jpeg,png,svg}"
82
83
  ];
83
84
 
85
+ const INDEX_SUFFIX_RE = /-index$/;
84
86
  function generateNuxtPageFromFile(file, pagesDir) {
85
- const segments = relative(pagesDir, file).replace(new RegExp(`${escapeRE(extname(file))}$`), "").split("/");
87
+ const extPattern = new RegExp(`${escapeRE(extname(file))}$`);
88
+ const segments = relative(pagesDir, file).replace(extPattern, "").split("/");
86
89
  const route = {
87
90
  name: "",
88
91
  path: "",
@@ -106,7 +109,7 @@ function generateNuxtPageFromFile(file, pagesDir) {
106
109
  }
107
110
  }
108
111
  if (route.name)
109
- route.name = route.name.replace(/-index$/, "");
112
+ route.name = route.name.replace(INDEX_SUFFIX_RE, "");
110
113
  return route;
111
114
  }
112
115
  function getRoutePath(tokens) {
@@ -160,7 +163,7 @@ function parseSegment(segment) {
160
163
  }
161
164
  if (c === "[" && state === 2 /* dynamic */)
162
165
  state = 3 /* optional */;
163
- if (c === "]" && (state !== 3 /* optional */ || buffer[buffer.length - 1] === "]")) {
166
+ if (c === "]" && (state !== 3 /* optional */ || buffer.at(-1) === "]")) {
164
167
  if (!buffer)
165
168
  throw new Error("Empty param");
166
169
  else
@@ -185,6 +188,7 @@ function hasLinkRel(input, rel) {
185
188
  function hasMetaProperty(input, property) {
186
189
  return input.meta?.some((meta) => meta.property === property);
187
190
  }
191
+ const ALT_NEWLINE_RE = /\n/g;
188
192
  async function getImageMeta(base, path, isIcon = false) {
189
193
  const absolutePath = resolve(base, path);
190
194
  const file = absolutePath.split("/").pop();
@@ -202,7 +206,7 @@ async function getImageMeta(base, path, isIcon = false) {
202
206
  const altTextPath = resolve(dirname(absolutePath), `${keyword}.alt.txt`);
203
207
  if (fs.existsSync(altTextPath)) {
204
208
  payload.alt = fs.readFileSync(altTextPath, "utf8");
205
- payload.alt = String(payload.alt).replace(/\n/g, " ").trim();
209
+ payload.alt = String(payload.alt).replace(ALT_NEWLINE_RE, " ").trim();
206
210
  }
207
211
  } else {
208
212
  if (path.includes(".dark") || path.includes("-dark"))
@@ -222,9 +226,10 @@ async function getImageDimensions(absolutePath) {
222
226
  return imageSize(buffer);
223
227
  }
224
228
 
229
+ const DIR_SUFFIX_RE = /\/_dir$/;
225
230
  async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
226
231
  const pagesDir = resolve(nuxt.options.rootDir, nuxt.options.dir.pages);
227
- const files = await fg(MetaTagFileGlobs, { cwd: pagesDir, onlyFiles: true });
232
+ const files = await glob(MetaTagFileGlobs, { cwd: pagesDir, onlyFiles: true });
228
233
  const appendRouteRules = {};
229
234
  const devMiddlewareMap = {};
230
235
  const nitroOutputMap = [];
@@ -234,7 +239,7 @@ async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
234
239
  let { path } = generateNuxtPageFromFile(resolve(pagesDir, dirname(file)), pagesDir);
235
240
  const meta = await getImageMeta(pagesDir, file);
236
241
  if (path.endsWith("/_dir"))
237
- path = path.replace(/\/_dir$/, "");
242
+ path = path.replace(DIR_SUFFIX_RE, "");
238
243
  const src = joinURL(path, fileName);
239
244
  if (["icon", "apple-touch-icon", "apple-icon"].includes(keyword) || keyword.startsWith("icon-")) {
240
245
  appendRouteRules[`${path}/**`] = {
@@ -280,7 +285,7 @@ async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
280
285
 
281
286
  async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
282
287
  const publicDirPath = resolve(nuxt.options.rootDir, nuxt.options.dir.public);
283
- const rootPublicFiles = (await fg(MetaTagFileGlobs, { cwd: publicDirPath, onlyFiles: true, deep: 1 })).map((file) => basename(file));
288
+ const rootPublicFiles = (await glob(MetaTagFileGlobs, { cwd: publicDirPath, onlyFiles: true, deep: 1 })).map((file) => basename(file));
284
289
  const headConfig = defu(nuxt.options.app.head, {
285
290
  link: [],
286
291
  meta: []
@@ -360,6 +365,163 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
360
365
  }
361
366
  }
362
367
  nuxt.options.app.head = headConfig;
368
+ const hasIcons = rootPublicFiles.some((f) => f.includes("icon") || f === "favicon.ico");
369
+ return { hasIcons };
370
+ }
371
+
372
+ const INLINE_SCRIPT_RE = /<script(?![^>]+\bsrc\b)([^>]*)>([\s\S]*?)<\/script\s*>/gi;
373
+ const INLINE_STYLE_RE = /<style([^>]*)>([\s\S]*?)<\/style\s*>/gi;
374
+ const TYPE_ATTR_RE = /\btype\s*=\s*["']([^"']*)["']/i;
375
+ const JSON_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json"]);
376
+ const SKIP_JS_TYPES = /* @__PURE__ */ new Set(["application/json", "application/ld+json", "speculationrules", "importmap"]);
377
+ const logger = useLogger("nuxt-seo-utils");
378
+ async function minifyJSBuildTime(code) {
379
+ try {
380
+ const rolldownPath = "rolldown/experimental";
381
+ const { minify } = await import(
382
+ /* @vite-ignore */
383
+ rolldownPath
384
+ );
385
+ const result = await minify("inline.js", code);
386
+ return result.code.trim();
387
+ } catch {
388
+ }
389
+ try {
390
+ const esbuild = await import('esbuild');
391
+ const result = await esbuild.transform(code, {
392
+ minify: true,
393
+ loader: "js"
394
+ });
395
+ return result.code.trim();
396
+ } catch {
397
+ }
398
+ logger.debug("Build-time JS minification skipped: neither rolldown nor esbuild is available. Install one as a dependency to enable it.");
399
+ return null;
400
+ }
401
+ async function minifyCSSWithLightningCSS(code) {
402
+ try {
403
+ const lightningcss = await import('lightningcss');
404
+ const result = lightningcss.transform({
405
+ filename: "inline.css",
406
+ code: new TextEncoder().encode(code),
407
+ minify: true
408
+ });
409
+ return new TextDecoder().decode(result.code).trim();
410
+ } catch {
411
+ logger.debug("Build-time CSS minification skipped: lightningcss is not available. Install it as a dependency to enable it.");
412
+ return null;
413
+ }
414
+ }
415
+ function minifyPrerender() {
416
+ const nuxt = useNuxt();
417
+ nuxt.hooks.hook("app:resolve", async () => {
418
+ const head = nuxt.options.app.head;
419
+ const promises = [];
420
+ if (head.script?.length) {
421
+ for (const script of head.script) {
422
+ if (typeof script === "string")
423
+ continue;
424
+ const content = String(script.innerHTML || script.textContent || "");
425
+ if (!content)
426
+ continue;
427
+ const setContent = (val) => {
428
+ if (script.innerHTML)
429
+ script.innerHTML = val;
430
+ else if (script.textContent)
431
+ script.textContent = val;
432
+ };
433
+ if (script.type && JSON_TYPES.has(script.type)) {
434
+ try {
435
+ const minified = JSON.stringify(JSON.parse(content));
436
+ if (minified.length < content.length)
437
+ setContent(minified);
438
+ } catch {
439
+ }
440
+ continue;
441
+ }
442
+ if (script.type && SKIP_JS_TYPES.has(script.type))
443
+ continue;
444
+ promises.push(minifyJSBuildTime(content).then((minified) => {
445
+ if (minified && minified.length < content.length)
446
+ setContent(minified);
447
+ }));
448
+ }
449
+ }
450
+ if (head.style?.length) {
451
+ for (const style of head.style) {
452
+ if (typeof style === "string")
453
+ continue;
454
+ const content = String(style.innerHTML || style.textContent || "");
455
+ if (!content)
456
+ continue;
457
+ promises.push(minifyCSSWithLightningCSS(content).then((minified) => {
458
+ if (minified && minified.length < content.length) {
459
+ if (style.innerHTML)
460
+ style.innerHTML = minified;
461
+ else if (style.textContent)
462
+ style.textContent = minified;
463
+ }
464
+ }));
465
+ }
466
+ }
467
+ await Promise.all(promises);
468
+ });
469
+ nuxt.hooks.hook("nitro:init", (nitro) => {
470
+ nitro.hooks.hook("prerender:generate", async (route) => {
471
+ if (!route.contents || !route.contentType?.includes("html"))
472
+ return;
473
+ route.contents = await replaceAsync(
474
+ route.contents,
475
+ new RegExp(INLINE_SCRIPT_RE.source, INLINE_SCRIPT_RE.flags),
476
+ async (fullMatch, attrs, content) => {
477
+ if (!content)
478
+ return fullMatch;
479
+ const typeMatch = TYPE_ATTR_RE.exec(attrs);
480
+ const type = typeMatch?.[1];
481
+ if (type && JSON_TYPES.has(type)) {
482
+ try {
483
+ const minified2 = JSON.stringify(JSON.parse(content));
484
+ if (minified2.length < content.length)
485
+ return `<script${attrs}>${minified2}<\/script>`;
486
+ } catch {
487
+ }
488
+ return fullMatch;
489
+ }
490
+ if (type && SKIP_JS_TYPES.has(type))
491
+ return fullMatch;
492
+ const minified = await minifyJSBuildTime(content);
493
+ if (minified && minified.length < content.length)
494
+ return `<script${attrs}>${minified}<\/script>`;
495
+ return fullMatch;
496
+ }
497
+ );
498
+ route.contents = await replaceAsync(
499
+ route.contents,
500
+ new RegExp(INLINE_STYLE_RE.source, INLINE_STYLE_RE.flags),
501
+ async (fullMatch, attrs, content) => {
502
+ if (!content)
503
+ return fullMatch;
504
+ const minified = await minifyCSSWithLightningCSS(content);
505
+ if (minified && minified.length < content.length)
506
+ return `<style${attrs}>${minified}</style>`;
507
+ return fullMatch;
508
+ }
509
+ );
510
+ });
511
+ });
512
+ }
513
+ async function replaceAsync(str, regex, asyncFn) {
514
+ const matches = [];
515
+ const re = new RegExp(regex.source, regex.flags);
516
+ for (let m = re.exec(str); m !== null; m = re.exec(str))
517
+ matches.push({ match: m, replacement: asyncFn(...m) });
518
+ const replacements = await Promise.all(matches.map((m) => m.replacement));
519
+ let result = str;
520
+ for (let i = matches.length - 1; i >= 0; i--) {
521
+ const { match } = matches[i];
522
+ result = result.slice(0, match.index) + replacements[i] + result.slice(match.index + match[0].length);
523
+ }
524
+ return result;
363
525
  }
364
526
 
365
527
  function setupNuxtConfigAppHeadWithMoreDefaults(nuxt = useNuxt()) {
@@ -377,7 +539,16 @@ function setupNuxtConfigAppHeadWithMoreDefaults(nuxt = useNuxt()) {
377
539
  nuxt.options.app.head = headConfig;
378
540
  }
379
541
 
380
- const module = defineNuxtModule({
542
+ function setupDevToolsUI(resolve, nuxt = useNuxt()) {
543
+ setupDevToolsUI$1({
544
+ route: "/__nuxt-seo-utils",
545
+ name: "nuxt-seo-utils",
546
+ title: "SEO Utils",
547
+ icon: "carbon:search-locate"
548
+ }, resolve, nuxt);
549
+ }
550
+
551
+ const module$1 = defineNuxtModule({
381
552
  meta: {
382
553
  name: "nuxt-seo-utils",
383
554
  configKey: "seo",
@@ -395,7 +566,7 @@ const module = defineNuxtModule({
395
566
  optional: true
396
567
  },
397
568
  "nuxt-site-config": {
398
- version: ">=3"
569
+ version: ">=3.2"
399
570
  }
400
571
  },
401
572
  defaults: {
@@ -413,7 +584,9 @@ const module = defineNuxtModule({
413
584
  extendNuxtConfigAppHeadTypes: true,
414
585
  setupNuxtConfigAppHeadWithMoreDefaults: true,
415
586
  automaticOgAndTwitterTags: true,
416
- canonicalLowercase: true
587
+ canonicalLowercase: true,
588
+ tagPriority: 30,
589
+ minify: true
417
590
  },
418
591
  async setup(config, nuxt) {
419
592
  const logger = useLogger("nuxt-seo-utils");
@@ -426,9 +599,17 @@ const module = defineNuxtModule({
426
599
  await installNuxtSiteConfig();
427
600
  const runtimeDir = resolve("./runtime");
428
601
  if (config.metaDataFiles) {
429
- await generateTagsFromPublicFiles();
602
+ const { hasIcons } = await generateTagsFromPublicFiles();
430
603
  await generateTagsFromPageDirImages();
431
604
  if (nuxt.options.dev) {
605
+ if (!hasIcons) {
606
+ const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir.public);
607
+ const logoPatterns = ["logo.svg", "logo.png", "icon.svg", "icon.png", "logo.jpg", "logo.webp"];
608
+ const foundSource = logoPatterns.find((p) => existsSync(resolve(publicDir, p))) || logoPatterns.find((p) => existsSync(resolve(nuxt.options.rootDir, p)));
609
+ const sourceArg = foundSource || "<your-logo.svg>";
610
+ logger.info(`No favicon detected. Generate icons with:
611
+ npx nuxt-seo-utils icons --source ${sourceArg}`);
612
+ }
432
613
  addServerHandler({
433
614
  middleware: true,
434
615
  handler: resolve(runtimeDir, "server/middleware/resolveImagesInPagesDir")
@@ -462,6 +643,14 @@ const module = defineNuxtModule({
462
643
  from: resolve(runtimeDir, `./app/composables/useBreadcrumbItems`),
463
644
  name: "useBreadcrumbItems"
464
645
  });
646
+ addImports({
647
+ from: resolve(runtimeDir, `./app/composables/useFallbackTitle`),
648
+ name: "useFallbackTitle"
649
+ });
650
+ addImports({
651
+ from: resolve(runtimeDir, `./app/composables/useShareLinks`),
652
+ name: "useShareLinks"
653
+ });
465
654
  const polyfills = [
466
655
  // @ts-expect-error runtime type
467
656
  ...!hasNuxtModule("nuxt-schema-org") || nuxt.options.schemaOrg?.enable === false ? [
@@ -495,7 +684,8 @@ const module = defineNuxtModule({
495
684
  "category",
496
685
  "tag"
497
686
  ],
498
- canonicalLowercase: config.canonicalLowercase
687
+ canonicalLowercase: config.canonicalLowercase,
688
+ tagPriority: config.tagPriority
499
689
  });
500
690
  if (config.extendNuxtConfigAppHeadSeoMeta)
501
691
  extendNuxtConfigAppHeadSeoMeta();
@@ -557,7 +747,28 @@ export {}
557
747
  addPlugin({ src: resolve(appRuntimeDir, "plugins", "0.routeRules") });
558
748
  if (config.fixRequiredAbsoluteMetaTagsLinks)
559
749
  addPlugin({ src: resolve(appRuntimeDir, "plugins", "1.absoluteImageUrls.server"), mode: "server" });
750
+ if (!nuxt.options.dev && config.minify) {
751
+ const minifyOpts = config.minify === true ? { build: true, runtime: true } : config.minify;
752
+ if (minifyOpts.runtime !== false) {
753
+ addPlugin({
754
+ src: resolve(appRuntimeDir, "plugins", "minifyScripts.server"),
755
+ mode: "server"
756
+ });
757
+ }
758
+ if (minifyOpts.build !== false) {
759
+ minifyPrerender();
760
+ }
761
+ }
762
+ if (config.debug || nuxt.options.dev) {
763
+ addServerHandler({
764
+ route: "/__nuxt-seo-utils/debug.json",
765
+ handler: resolve("./runtime/server/routes/__nuxt-seo-utils/debug")
766
+ });
767
+ }
768
+ if (nuxt.options.dev) {
769
+ setupDevToolsUI(resolve, nuxt);
770
+ }
560
771
  }
561
772
  });
562
773
 
563
- export { module as default };
774
+ export { module$1 as default };
@@ -1,11 +1,12 @@
1
+ import type { ComputedRef } from 'vue';
1
2
  export declare function useSchemaOrg(): void;
2
3
  export declare function defineWebSite(): void;
3
4
  export declare function defineWebPage(): void;
4
5
  export declare function defineBreadcrumb(): void;
5
6
  export declare function useI18n(): {
6
- t: (_: string, fallback: string, options: any) => string;
7
+ t: (_: string, fallback: string, _options: any) => string;
7
8
  te: (_: string) => boolean;
8
9
  strategy: string;
9
- defaultLocale: import("vue").ComputedRef<any>;
10
- locale: import("vue").ComputedRef<any>;
10
+ defaultLocale: ComputedRef<string>;
11
+ locale: ComputedRef<string>;
11
12
  };
@@ -13,8 +13,7 @@ export function useI18n() {
13
13
  resolveRefs: false
14
14
  });
15
15
  return {
16
- // eslint-disable-next-line unused-imports/no-unused-vars
17
- t: (_, fallback, options) => fallback,
16
+ t: (_, fallback, _options) => fallback,
18
17
  te: (_) => false,
19
18
  strategy: "no_prefix",
20
19
  defaultLocale: computed(() => {
@@ -1,5 +1,5 @@
1
1
  import type { NuxtLinkProps } from 'nuxt/app';
2
- import type { MaybeRefOrGetter } from 'vue';
2
+ import type { MaybeRefOrGetter, Ref } from 'vue';
3
3
  interface NuxtUIBreadcrumbItem extends NuxtLinkProps {
4
4
  label: string;
5
5
  labelClass?: string;
@@ -108,5 +108,5 @@ export interface BreadcrumbItemProps extends NuxtUIBreadcrumbItem {
108
108
  * @see https://github.com/harlan-zw/nuxt-seo/blob/main/src/runtime/nuxt/composables/useBreadcrumbItems.ts
109
109
  * @docs https://nuxtseo.com/nuxt-seo/api/breadcrumbs
110
110
  */
111
- export declare function useBreadcrumbItems(_options?: BreadcrumbProps): import("vue").ComputedRef<BreadcrumbItemProps[]>;
111
+ export declare function useBreadcrumbItems(_options?: BreadcrumbProps): Ref<BreadcrumbItemProps[]>;
112
112
  export {};
@@ -19,10 +19,11 @@ import {
19
19
  } from "vue";
20
20
  import { pathBreadcrumbSegments } from "../../shared/breadcrumbs.js";
21
21
  function withoutQuery(path) {
22
- return path.split("?")[0];
22
+ return path.split("?")[0] || "";
23
23
  }
24
+ const TITLE_CASE_RE = /\w\S*/g;
24
25
  function titleCase(s) {
25
- return s.replaceAll("-", " ").replace(/\w\S*/g, (w) => w.charAt(0).toUpperCase() + w.substr(1).toLowerCase());
26
+ return s.replaceAll("-", " ").replace(TITLE_CASE_RE, (w) => w.charAt(0).toUpperCase() + w.substr(1).toLowerCase());
26
27
  }
27
28
  const BreadcrumbCtx = Symbol("BreadcrumbCtx");
28
29
  export function useBreadcrumbItems(_options = {}) {
@@ -171,18 +172,17 @@ export function useBreadcrumbItems(_options = {}) {
171
172
  return m;
172
173
  }).filter(Boolean);
173
174
  });
174
- watch(items, (newItems) => {
175
+ watch(() => items.value, (newItems) => {
175
176
  if (!pauseUpdates.value) {
176
177
  lastBreadcrumbs.value = newItems;
177
178
  }
178
179
  }, { immediate: true, flush: "sync" });
179
180
  const schemaOrgEnabled = typeof _options.schemaOrg === "undefined" ? true : _options.schemaOrg;
180
- if ((import.meta.dev || import.meta.server || import.meta.env?.NODE_ENV === "test") && schemaOrgEnabled) {
181
+ if ((import.meta.server || import.meta.env?.NODE_ENV === "test") && schemaOrgEnabled) {
181
182
  useSchemaOrg([
182
- // @ts-expect-error untyped
183
183
  defineBreadcrumb({
184
- id: `#${id}`,
185
- itemListElement: computed(() => items.value.map((item) => ({
184
+ "@id": `#${id}`,
185
+ "itemListElement": computed(() => items.value.map((item) => ({
186
186
  name: item.label || item.ariaLabel,
187
187
  item: item.to ? siteResolver(item.to) : void 0
188
188
  })))
@@ -0,0 +1 @@
1
+ export declare function useFallbackTitle(): import("vue").ComputedRef<string | null>;
@@ -0,0 +1,31 @@
1
+ import { useI18n } from "#imports";
2
+ import { useError, useRoute } from "nuxt/app";
3
+ import { titleCase } from "scule";
4
+ import { withoutTrailingSlash } from "ufo";
5
+ import { computed } from "vue";
6
+ export function useFallbackTitle() {
7
+ const route = useRoute();
8
+ const err = useError();
9
+ let i18n;
10
+ try {
11
+ i18n = useI18n();
12
+ } catch {
13
+ }
14
+ return computed(() => {
15
+ if (err.value?.statusCode && [404, 500].includes(err.value.statusCode)) {
16
+ return `${err.value.statusCode} - ${err.value.message}`;
17
+ }
18
+ if (typeof route.meta?.title === "string")
19
+ return route.meta?.title;
20
+ const path = withoutTrailingSlash(route.path || "/");
21
+ const lastSegment = path.split("/").pop();
22
+ let fallback = lastSegment ? titleCase(lastSegment) : null;
23
+ const matched = route.matched?.at(-1);
24
+ if (matched) {
25
+ const routeName = String(matched.name).split("___")?.[0];
26
+ if (routeName && i18n)
27
+ fallback = i18n.t(`pages.${routeName}.title`, fallback || "", { missingWarn: false }) || fallback;
28
+ }
29
+ return fallback;
30
+ });
31
+ }
@@ -0,0 +1,78 @@
1
+ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
2
+ export type SharePlatform = 'twitter' | 'facebook' | 'linkedin' | 'whatsapp' | 'telegram' | 'reddit' | 'pinterest' | 'email';
3
+ export interface ShareLinkUtmParams {
4
+ /**
5
+ * Set to 'auto' to use the platform name as utm_source per link.
6
+ * When a string (other than 'auto'), use that value for all platforms.
7
+ * @default undefined
8
+ */
9
+ source?: 'auto' | (string & {});
10
+ medium?: string;
11
+ campaign?: string;
12
+ term?: string;
13
+ content?: string;
14
+ }
15
+ export interface ShareLinksOptions {
16
+ /**
17
+ * Override the URL to share. Defaults to the canonical URL of the current page.
18
+ */
19
+ url?: MaybeRefOrGetter<string | undefined>;
20
+ /**
21
+ * The title/text to share. Defaults to the site name from site config.
22
+ */
23
+ title?: MaybeRefOrGetter<string | undefined>;
24
+ /**
25
+ * UTM tracking parameters. Defaults to `true` for automatic per-platform tracking
26
+ * (utm_source = platform name, utm_medium = 'social'/'email'). Pass `false` to disable,
27
+ * or an object for manual control.
28
+ *
29
+ * @example
30
+ * // Auto: utm_source=twitter&utm_medium=social, utm_source=facebook&utm_medium=email, etc.
31
+ * utm: true
32
+ *
33
+ * // Manual: same utm params on all platforms
34
+ * utm: { source: 'newsletter', medium: 'email', campaign: 'launch' }
35
+ *
36
+ * // Hybrid: auto source + medium per platform, with custom campaign
37
+ * utm: { source: 'auto', campaign: 'launch' }
38
+ */
39
+ utm?: MaybeRefOrGetter<boolean | ShareLinkUtmParams | undefined>;
40
+ /**
41
+ * Twitter/X specific options.
42
+ */
43
+ twitter?: {
44
+ /** Twitter username to attribute (without @). */
45
+ via?: string;
46
+ /** Hashtags to include (without #). */
47
+ hashtags?: string[];
48
+ };
49
+ /**
50
+ * Facebook specific options.
51
+ */
52
+ facebook?: {
53
+ /** Pre-filled text to accompany the share. */
54
+ quote?: string;
55
+ /** A single hashtag (without #, e.g. "nuxt"). */
56
+ hashtag?: string;
57
+ };
58
+ /**
59
+ * Pinterest specific options.
60
+ */
61
+ pinterest?: {
62
+ /** Direct image URL to pin. */
63
+ media?: MaybeRefOrGetter<string | undefined>;
64
+ };
65
+ }
66
+ export type ShareLinks = Record<SharePlatform, string> & {
67
+ /** The resolved canonical URL (without UTM params), useful for "copy link" buttons. */
68
+ canonicalUrl: string;
69
+ };
70
+ /**
71
+ * Generate social share URLs for the current page across multiple platforms.
72
+ *
73
+ * Uses the site config base URL and canonical URL logic to build share links
74
+ * with optional UTM parameter tracking.
75
+ *
76
+ * @docs https://nuxtseo.com/nuxt-seo/api/share-links
77
+ */
78
+ export declare function useShareLinks(options?: ShareLinksOptions): ComputedRef<ShareLinks>;
@@ -0,0 +1,94 @@
1
+ import { useSiteConfig } from "#site-config/app/composables/useSiteConfig";
2
+ import { createSitePathResolver } from "#site-config/app/composables/utils";
3
+ import { useRoute, useRuntimeConfig } from "nuxt/app";
4
+ import { stringifyQuery, withQuery } from "ufo";
5
+ import { computed, toValue } from "vue";
6
+ function buildShareUrl(platform, url, title, options) {
7
+ const encodedUrl = encodeURIComponent(url);
8
+ const encodedTitle = encodeURIComponent(title);
9
+ switch (platform) {
10
+ case "twitter": {
11
+ let twitterUrl = `https://x.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
12
+ if (options.twitter?.via)
13
+ twitterUrl += `&via=${encodeURIComponent(options.twitter.via)}`;
14
+ if (options.twitter?.hashtags?.length)
15
+ twitterUrl += `&hashtags=${encodeURIComponent(options.twitter.hashtags.join(","))}`;
16
+ return twitterUrl;
17
+ }
18
+ case "facebook": {
19
+ let fbUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
20
+ if (options.facebook?.quote)
21
+ fbUrl += `&quote=${encodeURIComponent(options.facebook.quote)}`;
22
+ if (options.facebook?.hashtag)
23
+ fbUrl += `&hashtag=${encodeURIComponent(`#${options.facebook.hashtag}`)}`;
24
+ return fbUrl;
25
+ }
26
+ case "linkedin":
27
+ return `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
28
+ case "whatsapp":
29
+ return `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`;
30
+ case "telegram":
31
+ return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
32
+ case "reddit":
33
+ return `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`;
34
+ case "pinterest": {
35
+ let pinUrl = `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`;
36
+ const media = toValue(options.pinterest?.media);
37
+ if (media)
38
+ pinUrl += `&media=${encodeURIComponent(media)}`;
39
+ return pinUrl;
40
+ }
41
+ case "email":
42
+ return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
43
+ }
44
+ }
45
+ export function useShareLinks(options = {}) {
46
+ const route = useRoute();
47
+ const siteConfig = useSiteConfig();
48
+ const { canonicalQueryWhitelist, canonicalLowercase } = useRuntimeConfig().public["seo-utils"];
49
+ const resolveUrl = createSitePathResolver({ withBase: true, absolute: true });
50
+ return computed(() => {
51
+ const customUrl = toValue(options.url);
52
+ let canonicalUrl;
53
+ if (customUrl) {
54
+ canonicalUrl = customUrl;
55
+ } else {
56
+ let url = resolveUrl(route.path || "/").value || route.path;
57
+ if (canonicalLowercase) {
58
+ url = url.toLowerCase();
59
+ }
60
+ const filteredQuery = Object.fromEntries(
61
+ Object.entries(route.query).filter(([key]) => canonicalQueryWhitelist.includes(key)).sort(([a], [b]) => a.localeCompare(b))
62
+ );
63
+ canonicalUrl = Object.keys(filteredQuery).length ? `${url}?${stringifyQuery(filteredQuery)}` : url;
64
+ }
65
+ const utm = toValue(options.utm) ?? true;
66
+ const title = toValue(options.title) || siteConfig.name || "";
67
+ const platforms = ["twitter", "facebook", "linkedin", "whatsapp", "telegram", "reddit", "pinterest", "email"];
68
+ const result = { canonicalUrl };
69
+ for (const platform of platforms) {
70
+ const query = {};
71
+ if (utm === false) {
72
+ } else if (utm === true || typeof utm === "object" && utm.source === "auto") {
73
+ query.utm_source = platform;
74
+ query.utm_medium = utm !== true && utm?.medium || (platform === "email" ? "email" : "social");
75
+ } else if (utm) {
76
+ if (utm.source)
77
+ query.utm_source = utm.source;
78
+ if (utm.medium)
79
+ query.utm_medium = utm.medium;
80
+ }
81
+ if (typeof utm === "object") {
82
+ if (utm.campaign)
83
+ query.utm_campaign = utm.campaign;
84
+ if (utm.term)
85
+ query.utm_term = utm.term;
86
+ if (utm.content)
87
+ query.utm_content = utm.content;
88
+ }
89
+ const url = Object.keys(query).length > 0 ? withQuery(canonicalUrl, query) : canonicalUrl;
90
+ result[platform] = buildShareUrl(platform, url, title, options);
91
+ }
92
+ return result;
93
+ });
94
+ }