nuxt-seo-utils 8.6.0 → 9.0.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.
Files changed (53) hide show
  1. package/README.md +11 -15
  2. package/dist/cli.mjs +1 -1
  3. package/dist/module.d.mts +55 -3
  4. package/dist/module.d.ts +55 -3
  5. package/dist/module.json +2 -2
  6. package/dist/module.mjs +521 -31
  7. package/dist/runtime/app/composables/polyfills.d.ts +2 -2
  8. package/dist/runtime/app/composables/polyfills.js +2 -2
  9. package/dist/runtime/app/composables/schemaOrg.d.ts +2 -0
  10. package/dist/runtime/app/composables/schemaOrg.js +2 -0
  11. package/dist/runtime/app/composables/useBreadcrumbItems.js +3 -2
  12. package/dist/runtime/app/composables/useFallbackTitle.js +0 -8
  13. package/dist/runtime/app/index.d.ts +5 -0
  14. package/dist/runtime/app/index.js +3 -0
  15. package/dist/runtime/app/logic/applyDefaults.js +1 -2
  16. package/dist/runtime/app/logic/syncDerivedTags.js +25 -15
  17. package/dist/runtime/app/plugins/0.routeRules.d.ts +1 -1
  18. package/dist/runtime/app/plugins/0.routeRules.js +1 -1
  19. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.d.ts +1 -1
  20. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.js +1 -2
  21. package/dist/runtime/app/plugins/colorModeIcons.d.ts +1 -1
  22. package/dist/runtime/app/plugins/colorModeIcons.js +1 -1
  23. package/dist/runtime/app/plugins/defaults.d.ts +1 -1
  24. package/dist/runtime/app/plugins/defaultsWaitI18n.d.ts +1 -1
  25. package/dist/runtime/app/plugins/derivedTags.d.ts +1 -1
  26. package/dist/runtime/app/plugins/derivedTags.js +1 -2
  27. package/dist/runtime/app/plugins/inferSeoMetaPlugin.d.ts +1 -1
  28. package/dist/runtime/app/plugins/inferSeoMetaPlugin.js +2 -16
  29. package/dist/runtime/app/plugins/minifyScripts.server.d.ts +1 -1
  30. package/dist/runtime/app/plugins/minifyScripts.server.js +2 -11
  31. package/dist/runtime/app/plugins/siteConfig.d.ts +1 -1
  32. package/dist/runtime/app/plugins/siteConfig.js +1 -2
  33. package/dist/runtime/app/plugins/titles.d.ts +1 -1
  34. package/dist/runtime/app/plugins/titles.js +1 -2
  35. package/dist/runtime/app/plugins/titlesWaitI18n.d.ts +1 -1
  36. package/dist/runtime/app/plugins/titlesWaitI18n.js +1 -2
  37. package/dist/runtime/server/middleware/redirectCanonical.d.ts +1 -1
  38. package/dist/runtime/server/middleware/redirectCanonical.js +6 -6
  39. package/dist/runtime/server/middleware/redirects.d.ts +2 -0
  40. package/dist/runtime/server/middleware/redirects.js +21 -0
  41. package/dist/runtime/server/middleware/resolveImagesInPagesDir.d.ts +1 -1
  42. package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +5 -6
  43. package/dist/runtime/server/plugins/minifyHtml.js +2 -9
  44. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +27 -1
  45. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +2 -3
  46. package/dist/runtime/shared/minify.d.ts +0 -6
  47. package/dist/runtime/shared/minify.js +0 -60
  48. package/dist/runtime/shared/redirects.d.ts +40 -0
  49. package/dist/runtime/shared/redirects.js +59 -0
  50. package/dist/runtime/types.d.ts +15 -0
  51. package/dist/types.d.mts +1 -1
  52. package/package.json +42 -37
  53. package/skills/nuxt-seo-utils/SKILL.md +52 -6
package/dist/module.mjs CHANGED
@@ -1,21 +1,23 @@
1
1
  import fs, { existsSync } from 'node:fs';
2
- import { pathToFileURL } from 'node:url';
3
- import { useNuxt, addTypeTemplate, useLogger, defineNuxtModule, createResolver, addServerHandler, hasNuxtModule, addPlugin, addImports, hasNuxtCompatibility, directoryToURL, addVitePlugin } from '@nuxt/kit';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { pathToFileURL, fileURLToPath } from 'node:url';
4
+ import { useNuxt, addTypeTemplate, getLayerDirectories, useLogger, addServerHandler, hasNuxtModule, extendRouteRules, defineNuxtModule, createResolver, addPlugin, addImports, directoryToURL, addVitePlugin } from '@nuxt/kit';
4
5
  import { unpackMeta } from '@unhead/vue/utils';
5
6
  import { defu } from 'defu';
6
7
  import { resolveModulePath } from 'exsolve';
7
- import { installNuxtSiteConfig, useSiteConfig } from 'nuxt-site-config/kit';
8
- import { useModuleLogger, setupNitroRuntimeCompatibility, renderNitroTypeAugmentations, resolveHostUnheadMajor } from 'nuxtseo-shared/kit';
9
- import { resolve, extname, relative, dirname, basename } from 'pathe';
10
- import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
8
+ import { useSiteConfig } from 'nuxt-site-config/kit';
9
+ import { useModuleLogger, setupNitroRuntimeCompatibility, setupRuntimeAliases, renderNitroTypeAugmentations } from 'nuxtseo-shared/kit';
10
+ import { resolve, extname, relative, dirname, basename, join } from 'pathe';
11
11
  import { glob } from 'tinyglobby';
12
- import { encodePath, joinURL } from 'ufo';
12
+ import { encodePath, joinURL, withoutTrailingSlash } from 'ufo';
13
13
  import { c as classifyIconFilename, g as getIconRel, n as normalizeIconSizes, a as partitionColorModeIconLinks } from './shared/nuxt-seo-utils.NAV2YNQn.mjs';
14
14
  import escapeRE from 'escape-string-regexp';
15
- import { readFile, readdir } from 'node:fs/promises';
16
15
  import { Buffer } from 'node:buffer';
17
16
  import { TWITTER_IMAGE_FALLBACK_KEY } from '../dist/runtime/shared/derivedTagKeys.js';
18
- import { JSON_TYPES, SKIP_JS_TYPES } from '../dist/runtime/shared/minify.js';
17
+ import { JSON_TYPES } from '../dist/runtime/shared/minify.js';
18
+ import { resolveI18nConfig } from 'nuxtseo-shared/i18n';
19
+ import { isAbsoluteUrl, parseSourcePattern, createRedirectMatcher, resolveTarget, appendSearch, REDIRECT_CACHE_CONTROL } from '../dist/runtime/shared/redirects.js';
20
+ import { createNitroRouteRuleMatcher } from 'nuxtseo-shared/server';
19
21
  import { setupDevToolsUI as setupDevToolsUI$1 } from 'nuxtseo-shared/devtools';
20
22
 
21
23
  function extendNuxtConfigAppHeadSeoMeta(nuxt = useNuxt()) {
@@ -384,7 +386,7 @@ function addGeneratedRouteRule(nuxt, route, generatedRule) {
384
386
  nitroRouteRules[route] = defu(nitroRouteRules[route], structuredClone(rule));
385
387
  }
386
388
  async function generateTagsFromPageDirImages(nuxt = useNuxt()) {
387
- const pagesDirs = nuxt.options._layers.map((layer) => resolve(layer.config.srcDir || layer.cwd, layer.config.dir?.pages || "pages")).filter((dir) => fs.existsSync(dir));
389
+ const pagesDirs = getLayerDirectories(nuxt).map((layer) => layer.appPages).filter((dir) => fs.existsSync(dir));
388
390
  const appendRouteRules = {};
389
391
  const registeredFiles = /* @__PURE__ */ new Set();
390
392
  const devMiddlewareMap = {};
@@ -499,7 +501,7 @@ function enrichConfiguredIcon(link, baseURL, iconEntries, diagnostics) {
499
501
  };
500
502
  }
501
503
  async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
502
- const publicDirs = nuxt.options._layers.map((layer) => resolve(layer.config.rootDir, layer.config.dir?.public || "public"));
504
+ const publicDirs = getLayerDirectories(nuxt).map((layer) => layer.public);
503
505
  const seen = /* @__PURE__ */ new Set();
504
506
  const fileEntries = [];
505
507
  for (const dir of publicDirs) {
@@ -585,6 +587,7 @@ async function generateTagsFromPublicFiles(nuxt = useNuxt()) {
585
587
  return { hasIcons, diagnostics };
586
588
  }
587
589
 
590
+ const JS_TYPES = /* @__PURE__ */ new Set(["", "module", "text/javascript", "application/javascript", "text/ecmascript", "application/ecmascript"]);
588
591
  const logger = useLogger("nuxt-seo-utils");
589
592
  async function minifyJSBuildTime(code) {
590
593
  try {
@@ -633,7 +636,7 @@ function minifyStaticHead() {
633
636
  if (typeof script === "string")
634
637
  continue;
635
638
  const content = String(script.innerHTML || script.textContent || "");
636
- if (!content)
639
+ if (!content || script.src)
637
640
  continue;
638
641
  const setContent = (val) => {
639
642
  if (script.innerHTML)
@@ -650,7 +653,7 @@ function minifyStaticHead() {
650
653
  }
651
654
  continue;
652
655
  }
653
- if (script.type && SKIP_JS_TYPES.has(script.type))
656
+ if (!JS_TYPES.has(script.type?.toLowerCase().trim() ?? ""))
654
657
  continue;
655
658
  promises.push(minifyJSBuildTime(content).then((minified) => {
656
659
  if (minified && minified.length < content.length)
@@ -680,8 +683,6 @@ function minifyStaticHead() {
680
683
  }
681
684
 
682
685
  function resolveUnheadVitePluginSource(input, resolvePath) {
683
- if (input.unheadMajor < 3)
684
- return { _tag: "unsupported-v2" };
685
686
  const path = resolvePath("@unhead/vue/vite", {
686
687
  try: true,
687
688
  from: [...input.hostImportPaths, ...input.importPaths]
@@ -703,6 +704,394 @@ function setupNuxtConfigAppHeadWithMoreDefaults(nuxt = useNuxt()) {
703
704
  nuxt.options.app.head = headConfig;
704
705
  }
705
706
 
707
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
708
+ const MAX_HOPS = 10;
709
+ const DAY_MS = 864e5;
710
+ const MISSING_TARGET = "set `to` for a moved URL, or `statusCode: 410` for a gone URL";
711
+ function parseDate(value) {
712
+ if (typeof value !== "string" || !DATE_RE.test(value))
713
+ return void 0;
714
+ const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
715
+ return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value ? void 0 : date;
716
+ }
717
+ function parseEntry(source, input) {
718
+ const pattern = parseSourcePattern(source);
719
+ if (typeof pattern === "string")
720
+ return { issues: [{ _tag: "InvalidSource", level: "error", source, reason: pattern }] };
721
+ if (!input || typeof input !== "object")
722
+ return { issues: [{ _tag: "InvalidTarget", level: "error", source, to: input, reason: MISSING_TARGET }] };
723
+ const issues = [];
724
+ const addedAt = parseDate(input.addedAt);
725
+ if (!addedAt)
726
+ issues.push({ _tag: "InvalidDate", level: "warn", source, field: "addedAt", value: input.addedAt });
727
+ const reviewedAt = input.reviewedAt === void 0 ? void 0 : parseDate(input.reviewedAt);
728
+ if (input.reviewedAt !== void 0 && !reviewedAt)
729
+ issues.push({ _tag: "InvalidDate", level: "warn", source, field: "reviewedAt", value: input.reviewedAt });
730
+ const reviewFrom = reviewedAt && (!addedAt || reviewedAt > addedAt) ? reviewedAt : addedAt;
731
+ if (input.to === void 0) {
732
+ if (input.statusCode !== 410 && input.statusCode !== 404) {
733
+ issues.push(input.statusCode === void 0 ? { _tag: "InvalidTarget", level: "error", source, to: void 0, reason: MISSING_TARGET } : { _tag: "InvalidStatusCode", level: "error", source, statusCode: input.statusCode });
734
+ return { issues };
735
+ }
736
+ if (pattern._tag === "Prefix" && pattern.prefix === "") {
737
+ issues.push({ _tag: "InvalidSource", level: "error", source, reason: "a gone URL cannot match every path" });
738
+ return { issues };
739
+ }
740
+ return { entry: { _tag: "Gone", source, pattern, statusCode: input.statusCode, reviewFrom }, issues };
741
+ }
742
+ const to = input.to;
743
+ if (typeof to !== "string" || to.startsWith("//") || !(to.startsWith("/") || isAbsoluteUrl(to))) {
744
+ issues.push({ _tag: "InvalidTarget", level: "error", source, to, reason: "use a path that starts with `/`, or an absolute `https://` URL" });
745
+ return { issues };
746
+ }
747
+ if (to.includes("*") && !(to.endsWith("/**") && !to.slice(0, -3).includes("*"))) {
748
+ issues.push({ _tag: "InvalidTarget", level: "error", source, to, reason: "only a trailing `/**` is supported" });
749
+ return { issues };
750
+ }
751
+ if (to.endsWith("/**") && pattern._tag !== "Prefix") {
752
+ issues.push({ _tag: "InvalidTarget", level: "error", source, to, reason: "a target that ends in `/**` needs a source that ends in `/**`" });
753
+ return { issues };
754
+ }
755
+ const statusCode = input.statusCode ?? 301;
756
+ if (statusCode !== 301 && statusCode !== 308) {
757
+ issues.push({ _tag: "InvalidStatusCode", level: "error", source, statusCode });
758
+ return { issues };
759
+ }
760
+ return { entry: { _tag: "Moved", source, pattern, to, statusCode, reviewFrom }, issues };
761
+ }
762
+ function targetPath(to) {
763
+ return isAbsoluteUrl(to) ? void 0 : to.split(/[?#]/)[0];
764
+ }
765
+ function withSuffix(target, suffix) {
766
+ const hashIndex = suffix.indexOf("#");
767
+ const search = hashIndex === -1 ? suffix : suffix.slice(0, hashIndex);
768
+ const hash = hashIndex === -1 ? "" : suffix.slice(hashIndex);
769
+ return appendSearch(target, search) + (target.includes("#") ? "" : hash);
770
+ }
771
+ function flattenChains(entries) {
772
+ const match = createRedirectMatcher(entries);
773
+ const issues = [];
774
+ const loops = /* @__PURE__ */ new Set();
775
+ const resolved = entries.map((entry) => {
776
+ if (entry._tag !== "Moved")
777
+ return entry;
778
+ let to = entry.to;
779
+ const visited = [entry.source];
780
+ const hops = [entry.source, entry.to];
781
+ for (let i = 0; i < MAX_HOPS; i++) {
782
+ const path = targetPath(to);
783
+ if (path === void 0)
784
+ break;
785
+ const wildcard = to.endsWith("/**");
786
+ const lookup = wildcard ? path.slice(0, -3) || "/" : path;
787
+ const next = match(lookup);
788
+ if (!next || wildcard && next.pattern._tag !== "Prefix")
789
+ break;
790
+ if (visited.includes(next.source)) {
791
+ const key = [...new Set(visited)].sort().join("\n");
792
+ if (!loops.has(key)) {
793
+ loops.add(key);
794
+ issues.push({ _tag: "RedirectLoop", level: "error", sources: [...visited, next.source] });
795
+ }
796
+ return entry;
797
+ }
798
+ visited.push(next.source);
799
+ if (next._tag === "Gone") {
800
+ issues.push({ _tag: "TargetGone", level: "warn", source: entry.source, to, statusCode: next.statusCode });
801
+ break;
802
+ }
803
+ if (wildcard)
804
+ to = next.to.endsWith("/**") ? `${resolveTarget(next, lookup).replace(/\/$/, "")}/**` : next.to;
805
+ else
806
+ to = withSuffix(resolveTarget(next, path), to.slice(path.length));
807
+ hops.push(to);
808
+ }
809
+ if (to === entry.to)
810
+ return entry;
811
+ issues.push({ _tag: "ChainFlattened", level: "info", source: entry.source, hops });
812
+ return { ...entry, to };
813
+ });
814
+ return { entries: resolved, issues };
815
+ }
816
+ function findDueForReview(entries, { now, reviewAfterDays }) {
817
+ if (reviewAfterDays === false)
818
+ return [];
819
+ return entries.flatMap((entry) => {
820
+ if (!entry.reviewFrom)
821
+ return [];
822
+ const days = Math.floor((now.getTime() - entry.reviewFrom.getTime()) / DAY_MS);
823
+ if (days < reviewAfterDays)
824
+ return [];
825
+ return [{
826
+ _tag: "DueForReview",
827
+ level: "warn",
828
+ source: entry.source,
829
+ target: entry._tag === "Moved" ? entry.to : String(entry.statusCode),
830
+ since: entry.reviewFrom.toISOString().slice(0, 10),
831
+ days
832
+ }];
833
+ });
834
+ }
835
+ function planRedirects(input, options) {
836
+ const parsed = Object.entries(input || {}).map(([source, value]) => parseEntry(source, value));
837
+ const flattened = flattenChains(parsed.flatMap((p) => p.entry ? [p.entry] : []));
838
+ return {
839
+ entries: flattened.entries,
840
+ issues: [...parsed.flatMap((p) => p.issues), ...flattened.issues, ...findDueForReview(flattened.entries, options)]
841
+ };
842
+ }
843
+ function resolveLocalePrefixes(i18n) {
844
+ if (!i18n.strategy || i18n.strategy === "no_prefix")
845
+ return [];
846
+ return i18n.locales.filter((locale) => i18n.strategy !== "prefix_except_default" || locale.code !== i18n.defaultLocale).map((locale) => `/${locale.code}`);
847
+ }
848
+ function withPrefix(prefix, pattern) {
849
+ return pattern._tag === "Exact" ? { _tag: "Exact", path: pattern.path === "/" ? prefix : `${prefix}${pattern.path}` } : { _tag: "Prefix", prefix: `${prefix}${pattern.prefix}` };
850
+ }
851
+ function sourceKey$2(pattern) {
852
+ return pattern._tag === "Exact" ? pattern.path : `${pattern.prefix}/**`;
853
+ }
854
+ function expandLocalePrefixes(entries, prefixes) {
855
+ if (!prefixes.length)
856
+ return entries;
857
+ const taken = new Set(entries.map((entry) => sourceKey$2(entry.pattern)));
858
+ const isPrefixed = (key) => prefixes.some((prefix) => key === prefix || key.startsWith(`${prefix}/`));
859
+ const expanded = entries.flatMap((entry) => isPrefixed(sourceKey$2(entry.pattern)) ? [] : prefixes.flatMap((prefix) => {
860
+ const pattern = withPrefix(prefix, entry.pattern);
861
+ const key = sourceKey$2(pattern);
862
+ if (taken.has(key))
863
+ return [];
864
+ taken.add(key);
865
+ if (entry._tag === "Gone")
866
+ return [{ ...entry, pattern }];
867
+ return [{ ...entry, pattern, to: isAbsoluteUrl(entry.to) ? entry.to : joinURL(prefix, entry.to) }];
868
+ }));
869
+ return [...entries, ...expanded];
870
+ }
871
+ function toRedirectRules(entries) {
872
+ return entries.map((entry) => entry._tag === "Moved" ? { _tag: "Moved", pattern: entry.pattern, to: entry.to, statusCode: entry.statusCode } : { _tag: "Gone", pattern: entry.pattern, statusCode: entry.statusCode });
873
+ }
874
+ function formatRedirectIssue(issue) {
875
+ switch (issue._tag) {
876
+ case "InvalidSource":
877
+ return `\`${issue.source}\` is not a valid source: ${issue.reason}.`;
878
+ case "InvalidTarget":
879
+ return `\`${issue.source}\` has an invalid target ${JSON.stringify(issue.to)}: ${issue.reason}.`;
880
+ case "InvalidStatusCode":
881
+ return `\`${issue.source}\` has status code ${JSON.stringify(issue.statusCode)}. Use 301 or 308 with \`to\`, and 410 or 404 without it.`;
882
+ case "RedirectLoop":
883
+ return `Redirect loop: ${issue.sources.join(" \u2192 ")}. Remove one of these entries.`;
884
+ case "InvalidDate":
885
+ return `\`${issue.source}\` has ${issue.field} ${JSON.stringify(issue.value)}. Use a date such as '2026-10-13'. The entry still works, but the review check skips it.`;
886
+ case "TargetGone":
887
+ return `\`${issue.source}\` redirects to \`${issue.to}\`, which returns ${issue.statusCode}. Pick a live target, or make \`${issue.source}\` a gone URL.`;
888
+ case "DueForReview":
889
+ return `\`${issue.source}\` \u2192 ${issue.target} (since ${issue.since}, ${issue.days} days)`;
890
+ case "ChainFlattened":
891
+ return `${issue.hops.join(" \u2192 ")}. \`${issue.source}\` now redirects to \`${issue.hops.at(-1)}\` in one hop.`;
892
+ }
893
+ }
894
+ function formatRedirectReport(issues, reviewAfterDays) {
895
+ const lines = (level, exclude) => issues.filter((issue) => issue.level === level && issue._tag !== exclude).map((issue) => ` - ${formatRedirectIssue(issue)}`);
896
+ const errors = lines("error");
897
+ const warnings = lines("warn", "DueForReview");
898
+ const due = issues.filter((issue) => issue._tag === "DueForReview").map((issue) => ` - ${formatRedirectIssue(issue)}`);
899
+ const flattened = lines("info");
900
+ const report = {};
901
+ if (errors.length)
902
+ report.error = `Invalid \`seo.redirects\` entries:
903
+ ${errors.join("\n")}`;
904
+ const warn = [
905
+ ...warnings.length ? [`Check these \`seo.redirects\` entries:
906
+ ${warnings.join("\n")}`] : [],
907
+ ...due.length ? [[
908
+ `${due.length} \`seo.redirects\` ${due.length === 1 ? "entry is" : "entries are"} due for review (older than ${reviewAfterDays} days):`,
909
+ ...due,
910
+ " If an old URL still gets visits or backlinks, keep it and set `reviewedAt` to today.",
911
+ " If it gets none, you can delete the entry."
912
+ ].join("\n")] : []
913
+ ];
914
+ if (warn.length)
915
+ report.warn = warn.join("\n");
916
+ if (flattened.length)
917
+ report.info = `Flattened \`seo.redirects\` chains:
918
+ ${flattened.join("\n")}`;
919
+ return report;
920
+ }
921
+
922
+ const ERROR_PAGE = "/404.html";
923
+ const HOST_NAMES = {
924
+ Netlify: "Netlify",
925
+ CloudflarePages: "Cloudflare Pages",
926
+ Vercel: "Vercel"
927
+ };
928
+ function isRootBase(baseURL) {
929
+ return (withoutTrailingSlash(baseURL) || "/") === "/";
930
+ }
931
+ function resolveStaticHost(preset, baseURL) {
932
+ switch (preset) {
933
+ case "netlify-static":
934
+ return isRootBase(baseURL) ? { _tag: "Netlify" } : { _tag: "MetaRefresh", reason: "Nitro writes the Netlify `_redirects` file inside `app.baseURL`, and Netlify does not read it there." };
935
+ case "cloudflare-pages-static":
936
+ return { _tag: "CloudflarePages" };
937
+ case "vercel-static":
938
+ return { _tag: "Vercel" };
939
+ default:
940
+ return { _tag: "MetaRefresh", reason: `The \`${preset}\` preset writes no host redirect file.` };
941
+ }
942
+ }
943
+ function sourceKey$1(rule) {
944
+ return rule.pattern._tag === "Exact" ? rule.pattern.path : `${rule.pattern.prefix}/**`;
945
+ }
946
+ function withPrefixPaths(rules) {
947
+ const exact = new Set(rules.flatMap((rule) => rule.pattern._tag === "Exact" ? [rule.pattern.path] : []));
948
+ const prefixPaths = rules.flatMap((rule) => {
949
+ if (rule.pattern._tag !== "Prefix")
950
+ return [];
951
+ const path = rule.pattern.prefix || "/";
952
+ if (exact.has(path))
953
+ return [];
954
+ exact.add(path);
955
+ const pattern = { _tag: "Exact", path };
956
+ return [rule._tag === "Moved" ? { ...rule, pattern, to: resolveTarget(rule, path) } : { ...rule, pattern }];
957
+ });
958
+ const prefixLength = (rule) => rule.pattern._tag === "Prefix" ? rule.pattern.prefix.length : Infinity;
959
+ return [...rules, ...prefixPaths].sort((a, b) => prefixLength(b) - prefixLength(a));
960
+ }
961
+ function toSplat(to) {
962
+ return to.endsWith("/**") ? `${to.slice(0, -3)}/:splat` : to;
963
+ }
964
+ function trimSlash(path) {
965
+ return path === "/" ? "" : withoutTrailingSlash(path);
966
+ }
967
+ function escapeRegExp(value) {
968
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
969
+ }
970
+ function vercelRoute(rule, baseURL) {
971
+ const path = trimSlash(joinURL(baseURL, rule.pattern._tag === "Exact" ? rule.pattern.path : rule.pattern.prefix || "/"));
972
+ const src = rule.pattern._tag === "Prefix" ? `^${escapeRegExp(path)}/(.*)$` : path ? `^${escapeRegExp(path)}/?$` : "^/$";
973
+ if (rule._tag === "Gone")
974
+ return { src, status: rule.statusCode, dest: joinURL(baseURL, ERROR_PAGE) };
975
+ const wildcard = rule.to.endsWith("/**");
976
+ const target = wildcard ? rule.to.slice(0, -3) || "/" : rule.to;
977
+ const location = isAbsoluteUrl(target) ? target : joinURL(baseURL, target);
978
+ return {
979
+ src,
980
+ status: rule.statusCode,
981
+ headers: {
982
+ "Location": wildcard ? `${trimSlash(location)}/$1` : location,
983
+ "cache-control": REDIRECT_CACHE_CONTROL
984
+ }
985
+ };
986
+ }
987
+ function nativeRedirect(to, statusCode) {
988
+ return { to, statusCode, status: statusCode };
989
+ }
990
+ function planNativeRedirects(rules, host, ctx) {
991
+ const servesGone = host._tag !== "CloudflarePages" && ctx.errorPage;
992
+ const served = withPrefixPaths(rules.filter((rule) => rule._tag === "Moved" || servesGone));
993
+ const gone = rules.filter((rule) => rule._tag === "Gone");
994
+ const unservedGone = servesGone ? [] : gone.filter((rule) => host._tag !== "CloudflarePages" || rule.statusCode === 410);
995
+ const unserved = unservedGone.length ? { _tag: host._tag === "CloudflarePages" ? "NoGoneStatus" : "NoErrorPage", sources: unservedGone.map(sourceKey$1) } : void 0;
996
+ if (host._tag === "Vercel")
997
+ return { output: { _tag: "VercelRoutes", routes: served.map((rule) => vercelRoute(rule, ctx.baseURL)) }, unserved };
998
+ const redirects = served.map((rule) => [
999
+ sourceKey$1(rule),
1000
+ rule._tag === "Moved" ? nativeRedirect(toSplat(rule.to), rule.statusCode) : nativeRedirect(ERROR_PAGE, rule.statusCode)
1001
+ ]);
1002
+ return { output: { _tag: "RouteRules", redirects: redirects.reverse() }, unserved };
1003
+ }
1004
+ function nativeSourceKeys(rules) {
1005
+ return [...new Set(withPrefixPaths(rules).map(sourceKey$1))];
1006
+ }
1007
+ function formatUnservedGone(host, unserved) {
1008
+ const name = HOST_NAMES[host._tag];
1009
+ const intro = unserved._tag === "NoGoneStatus" ? `${name} cannot send 410 from \`_redirects\`. These gone URLs return the host 404 page instead:` : `The build has no \`404.html\` page, so ${name} cannot serve these gone URLs. They return the host 404 page:`;
1010
+ return [intro, ...unserved.sources.map((source) => ` - ${source}`)].join("\n");
1011
+ }
1012
+ function formatMetaRefreshFallback(reason, unserved) {
1013
+ return [
1014
+ `${reason} So this static build cannot send a redirect or 410 status.`,
1015
+ "Each exact moved URL gets an HTML page with an instant meta refresh.",
1016
+ ...unserved.length ? ["These entries need host redirect rules, or they return the host 404 page:", ...unserved.map((source) => ` - ${source}`)] : []
1017
+ ].join("\n");
1018
+ }
1019
+
1020
+ function sourceKey(rule) {
1021
+ return rule.pattern._tag === "Exact" ? rule.pattern.path : `${rule.pattern.prefix}/**`;
1022
+ }
1023
+ function setupRedirects(redirects, reviewAfterDays, ctx) {
1024
+ const { nuxt, logger } = ctx;
1025
+ if (!redirects || !Object.keys(redirects).length || nuxt.options._prepare)
1026
+ return;
1027
+ const plan = planRedirects(redirects, { now: ctx.now, reviewAfterDays });
1028
+ const report = formatRedirectReport(plan.issues, reviewAfterDays);
1029
+ if (report.error)
1030
+ throw new Error(`[nuxt-seo-utils] ${report.error}`);
1031
+ if (report.warn)
1032
+ logger.warn(report.warn);
1033
+ if (report.info)
1034
+ logger.info(report.info);
1035
+ if (!plan.entries.length)
1036
+ return;
1037
+ addServerHandler({ middleware: true, handler: ctx.middleware });
1038
+ let rules = toRedirectRules(plan.entries);
1039
+ nuxt.hook("modules:done", async () => {
1040
+ const i18n = ctx.hasI18n ? await resolveI18nConfig(logger) : false;
1041
+ if (i18n && !i18n.differentDomains && !i18n.multiDomainLocales)
1042
+ rules = expandLocalePrefixes(rules, resolveLocalePrefixes(i18n));
1043
+ nuxt.options.runtimeConfig["seo-utils-redirects"] = rules;
1044
+ if (hasNuxtModule("@nuxtjs/sitemap", nuxt)) {
1045
+ for (const rule of rules)
1046
+ extendRouteRules(sourceKey(rule), { sitemap: false });
1047
+ }
1048
+ });
1049
+ nuxt.hook("nitro:init", (nitro) => {
1050
+ const { baseURL, routeRules } = nitro.options;
1051
+ const setPrerender = (keys, prerender) => {
1052
+ for (const key of keys)
1053
+ routeRules[key] = defu(routeRules[key], { prerender });
1054
+ };
1055
+ const blockPrerender = (keys) => setPrerender(
1056
+ (withoutTrailingSlash(baseURL) || "/") === "/" ? keys : keys.flatMap((key) => [key, joinURL(baseURL, key)]),
1057
+ false
1058
+ );
1059
+ if (!nitro.options.static) {
1060
+ blockPrerender(rules.map(sourceKey));
1061
+ return;
1062
+ }
1063
+ const host = resolveStaticHost(nitro.options.preset, baseURL);
1064
+ if (host._tag === "MetaRefresh") {
1065
+ const pages = rules.filter((rule) => rule._tag === "Moved" && rule.pattern._tag === "Exact");
1066
+ const unserved = rules.filter((rule) => !pages.includes(rule)).map(sourceKey);
1067
+ blockPrerender(rules.filter((rule) => rule._tag === "Gone").map(sourceKey));
1068
+ setPrerender(pages.map(sourceKey), true);
1069
+ if (unserved.length)
1070
+ logger.warn(formatMetaRefreshFallback(host.reason, unserved));
1071
+ return;
1072
+ }
1073
+ blockPrerender(nativeSourceKeys(rules));
1074
+ nitro.hooks.hook("prerender:done", () => {
1075
+ const plan2 = planNativeRedirects(rules, host, {
1076
+ baseURL,
1077
+ errorPage: existsSync(join(nitro.options.output.publicDir, "404.html"))
1078
+ });
1079
+ if (plan2.output._tag === "VercelRoutes") {
1080
+ const config = (nitro.options.vercel ||= { config: { version: 3 } }).config ||= { version: 3 };
1081
+ config.routes = [...config.routes || [], ...plan2.output.routes];
1082
+ } else {
1083
+ for (const [key, redirect] of plan2.output.redirects) {
1084
+ const existing = nitro.options.routeRules[key];
1085
+ delete nitro.options.routeRules[key];
1086
+ nitro.options.routeRules[key] = defu(existing, { redirect });
1087
+ }
1088
+ }
1089
+ if (plan2.unserved)
1090
+ logger.warn(formatUnservedGone(host, plan2.unserved));
1091
+ });
1092
+ });
1093
+ }
1094
+
706
1095
  const NUXT_DEFAULT_VIEWPORT = "width=device-width, initial-scale=1";
707
1096
  const UNDERSCORE_RE = /_/g;
708
1097
  const HYPHEN_RE = /-/g;
@@ -946,6 +1335,66 @@ function collectUserAppHead(nuxt) {
946
1335
  return head;
947
1336
  }
948
1337
 
1338
+ const NOINDEX = /(?:^|,)\s*(?:noindex|none)\s*(?:,|$)/i;
1339
+ const NON_PAGE_PATH = /^\/(?:api|_nuxt|_nitro|__nuxt|\.well-known)(?:\/|$)/;
1340
+ function isIndexable(robots) {
1341
+ if (robots === false)
1342
+ return false;
1343
+ if (typeof robots === "string")
1344
+ return !NOINDEX.test(robots);
1345
+ if (robots && typeof robots === "object") {
1346
+ if (typeof robots.indexable === "boolean")
1347
+ return robots.indexable;
1348
+ return !robots.noindex && !robots.none && !(robots.rule && NOINDEX.test(robots.rule));
1349
+ }
1350
+ return true;
1351
+ }
1352
+ function validateRendering(context) {
1353
+ if (context.indexable === false)
1354
+ return [];
1355
+ const routeRules = context.routeRules || {};
1356
+ const match = createNitroRouteRuleMatcher({ nitro: { routeRules } });
1357
+ const candidates = [
1358
+ ...context.ssr === false ? ["/**"] : [],
1359
+ ...Object.keys(routeRules)
1360
+ ];
1361
+ const diagnostics = [];
1362
+ for (const path of new Set(candidates)) {
1363
+ const rule = match(path);
1364
+ const contentType = Object.entries(rule.headers || {}).find(([name]) => name.toLowerCase() === "content-type")?.[1];
1365
+ if (context.ssr !== false && rule.ssr !== false || !isIndexable(rule.robots) || NON_PAGE_PATH.test(path) || rule.redirect || rule.proxy || typeof contentType === "string" && !/^text\/html(?:;|$)/i.test(contentType)) {
1366
+ continue;
1367
+ }
1368
+ if (diagnostics.some((diagnostic) => diagnostic.path === "/**"))
1369
+ continue;
1370
+ diagnostics.push({ _tag: "ClientRendering", level: "warn", path });
1371
+ }
1372
+ return diagnostics;
1373
+ }
1374
+ function formatRenderingDiagnostic(diagnostic) {
1375
+ const path = JSON.stringify(diagnostic.path);
1376
+ return [
1377
+ `SSR is disabled for indexable pages matching \`${diagnostic.path}\`.`,
1378
+ "Crawlers and agents that skip JavaScript may miss their page content.",
1379
+ "For public pages, enable global SSR and restore SSR on the route:",
1380
+ "```ts [nuxt.config.ts]",
1381
+ "export default defineNuxtConfig({",
1382
+ " ssr: true,",
1383
+ ` routeRules: { ${path}: { ssr: true } },`,
1384
+ "})",
1385
+ "```",
1386
+ "For private app pages, keep their rendering rule and disable indexing with Nuxt Robots:",
1387
+ "```ts [nuxt.config.ts]",
1388
+ "export default defineNuxtConfig({",
1389
+ " modules: ['@nuxtjs/robots'],",
1390
+ ` routeRules: { ${path}: { robots: false } },`,
1391
+ "})",
1392
+ "```",
1393
+ "Requires `@nuxtjs/robots`. Install it with `pnpm add @nuxtjs/robots`.",
1394
+ "Merge the chosen example into your existing Nuxt config."
1395
+ ].join("\n");
1396
+ }
1397
+
949
1398
  function setupDevToolsUI(resolve, nuxt = useNuxt()) {
950
1399
  setupDevToolsUI$1({
951
1400
  route: "/__nuxt-seo-utils",
@@ -960,7 +1409,7 @@ const module$1 = defineNuxtModule({
960
1409
  name: "nuxt-seo-utils",
961
1410
  configKey: "seo",
962
1411
  compatibility: {
963
- nuxt: ">=3.16.0"
1412
+ nuxt: "^4.6.0 || ^5.0.0-0"
964
1413
  }
965
1414
  },
966
1415
  moduleDependencies: {
@@ -972,14 +1421,17 @@ const module$1 = defineNuxtModule({
972
1421
  version: ">=1",
973
1422
  optional: true
974
1423
  },
975
- "nuxt-site-config": {
976
- version: ">=3.2"
1424
+ // Keep package-relative resolution until the minimum Nuxt version includes this fix:
1425
+ // https://github.com/nuxt/nuxt/pull/36542
1426
+ [fileURLToPath(import.meta.resolve("nuxt-site-config"))]: {
1427
+ version: ">=5.0.0"
977
1428
  }
978
1429
  },
979
1430
  defaults: {
980
1431
  enabled: true,
981
1432
  debug: false,
982
1433
  redirectToCanonicalSiteUrl: false,
1434
+ redirectReviewAfterDays: 365,
983
1435
  automaticDefaults: true,
984
1436
  fallbackTitle: true,
985
1437
  metaDataFiles: true,
@@ -1004,9 +1456,8 @@ const module$1 = defineNuxtModule({
1004
1456
  return;
1005
1457
  }
1006
1458
  const nitroCompatibility = setupNitroRuntimeCompatibility(nuxt);
1007
- const { resolve } = createResolver(import.meta.url);
1008
- const { version } = await readPackageJSON(resolve("../package.json"));
1009
- await installNuxtSiteConfig();
1459
+ const { resolve, resolvePath } = createResolver(import.meta.url);
1460
+ const { version } = JSON.parse(await readFile(resolve("../package.json"), "utf8"));
1010
1461
  const runtimeDir = resolve("./runtime");
1011
1462
  if (config.metaDataFiles) {
1012
1463
  const { hasIcons, diagnostics } = await generateTagsFromPublicFiles();
@@ -1079,7 +1530,7 @@ const module$1 = defineNuxtModule({
1079
1530
  });
1080
1531
  const polyfills = [
1081
1532
  // @ts-expect-error runtime type
1082
- ...!hasNuxtModule("nuxt-schema-org") || nuxt.options.schemaOrg?.enable === false ? [
1533
+ ...!hasNuxtModule("nuxt-schema-org") || nuxt.options.schemaOrg?.enabled === false ? [
1083
1534
  "useSchemaOrg",
1084
1535
  "defineWebSite",
1085
1536
  "defineWebPage",
@@ -1100,7 +1551,16 @@ const module$1 = defineNuxtModule({
1100
1551
  middleware: true
1101
1552
  });
1102
1553
  }
1103
- nuxt.options.alias["#seo-utils"] = runtimeDir;
1554
+ setupRedirects(config.redirects, config.redirectReviewAfterDays ?? 365, {
1555
+ nuxt,
1556
+ logger,
1557
+ middleware: resolve(runtimeDir, "./server/middleware/redirects"),
1558
+ now: /* @__PURE__ */ new Date(),
1559
+ hasI18n
1560
+ });
1561
+ setupRuntimeAliases({ namespace: "#seo-utils", app: resolve(runtimeDir, "./app") }, nuxt);
1562
+ const hasSchemaOrg = hasNuxtModule("nuxt-schema-org") && nuxt.options.schemaOrg?.enabled !== false;
1563
+ nuxt.options.alias["#seo-utils-schema"] = hasSchemaOrg ? resolve(runtimeDir, "./app/composables/schemaOrg") : resolve(runtimeDir, "./app/composables/polyfills");
1104
1564
  nuxt.options.runtimeConfig.public["nuxt-seo-utils-version"] = nuxt.options.runtimeConfig.public["nuxt-seo-utils-version"] || version || "";
1105
1565
  nuxt.options.runtimeConfig.public["seo-utils"] = defu(nuxt.options.runtimeConfig.public["seo-utils"] || {}, {
1106
1566
  canonicalQueryWhitelist: config.canonicalQueryWhitelist ?? [
@@ -1174,17 +1634,15 @@ export {}
1174
1634
  { vite: config.treeShakeUseSeoMeta ? { validate: !nuxt.options.test } : false }
1175
1635
  );
1176
1636
  const viteOpts = nuxt.options.unhead.vite;
1177
- const nuxtRegistersUnheadVite = nuxt.options.future?.compatibilityVersion >= 5 && nuxt.options.builder === "@nuxt/vite-builder" && await hasNuxtCompatibility({ nuxt: ">=4.5.0" });
1637
+ const nuxtRegistersUnheadVite = nuxt.options.future?.compatibilityVersion >= 5 && nuxt.options.builder === "@nuxt/vite-builder";
1178
1638
  if (!nuxtRegistersUnheadVite && viteOpts !== false) {
1179
- const unheadMajor = await resolveHostUnheadMajor(nuxt.options.rootDir);
1180
1639
  const importPaths = nuxt.options.modulesDir.map((d) => directoryToURL(d));
1181
1640
  const rolldownPath = resolveModulePath("rolldown/experimental", { try: true, from: importPaths });
1182
1641
  const lightningcssPath = resolveModulePath("lightningcss", { try: true, from: importPaths });
1183
1642
  const rolldownURL = rolldownPath ? pathToFileURL(rolldownPath).href : void 0;
1184
1643
  const lightningcssURL = lightningcssPath ? pathToFileURL(lightningcssPath).href : void 0;
1185
- const hostImportPaths = unheadMajor >= 3 ? [directoryToURL(dirname(await resolvePackageJSON("nuxt", { url: directoryToURL(nuxt.options.rootDir).href })))] : [];
1644
+ const hostImportPaths = [directoryToURL(dirname(resolveModulePath("nuxt/package.json", { from: importPaths })))];
1186
1645
  const vitePluginSource = resolveUnheadVitePluginSource({
1187
- unheadMajor,
1188
1646
  hostImportPaths,
1189
1647
  importPaths
1190
1648
  }, resolveModulePath);
@@ -1202,9 +1660,7 @@ export {}
1202
1660
  }).code).trim();
1203
1661
  } : void 0
1204
1662
  };
1205
- if (vitePluginSource._tag === "unsupported-v2") {
1206
- logger.warn("`treeShakeUseSeoMeta` requires Unhead v3; skipping the transform on Unhead v2.");
1207
- } else if (vitePluginSource._tag === "missing-vue") {
1663
+ if (vitePluginSource._tag === "missing-vue") {
1208
1664
  logger.warn("`treeShakeUseSeoMeta` requires `@unhead/vue` with the `/vite` export. Install Unhead v3 or disable `treeShakeUseSeoMeta`.");
1209
1665
  } else {
1210
1666
  addVitePlugin(async () => {
@@ -1215,8 +1671,25 @@ export {}
1215
1671
  });
1216
1672
  }
1217
1673
  }
1218
- if (config.automaticOgAndTwitterTags)
1674
+ if (config.automaticOgAndTwitterTags) {
1675
+ const minifyImports = ["unhead/minify", await resolvePath("unhead/minify")];
1676
+ nuxt.hook("nitro:config", (nitroConfig) => {
1677
+ if (nitroCompatibility._tag === "nitro-v3") {
1678
+ const nitro3Config = nitroConfig;
1679
+ if (nitro3Config.noExternals !== true) {
1680
+ nitro3Config.noExternals = [
1681
+ ...Array.isArray(nitro3Config.noExternals) ? nitro3Config.noExternals : [],
1682
+ ...minifyImports
1683
+ ];
1684
+ }
1685
+ } else {
1686
+ nitroConfig.externals ||= {};
1687
+ nitroConfig.externals.inline ||= [];
1688
+ nitroConfig.externals.inline.push(...minifyImports);
1689
+ }
1690
+ });
1219
1691
  addPlugin({ src: resolve(appRuntimeDir, "plugins", "inferSeoMetaPlugin") });
1692
+ }
1220
1693
  if (config.mergeWithSiteConfig)
1221
1694
  addPlugin({ src: resolve(appRuntimeDir, "plugins", "siteConfig") });
1222
1695
  if (config.extendRouteRules)
@@ -1257,6 +1730,23 @@ export {}
1257
1730
  handler: resolve("./runtime/server/routes/__nuxt-seo-utils/debug")
1258
1731
  });
1259
1732
  }
1733
+ if (!nuxt.options._prepare) {
1734
+ nuxt.hooks.afterEach((event) => {
1735
+ if (event.name !== "nitro:config")
1736
+ return;
1737
+ const [nitroConfig] = event.args;
1738
+ const site = nuxt.options.site;
1739
+ const robots = nuxt.options.robots;
1740
+ const diagnostics = validateRendering({
1741
+ ssr: nuxt.options.ssr,
1742
+ indexable: site?.indexable === false || robots && robots.indexable === false ? false : void 0,
1743
+ routeRules: defu(nitroConfig.routeRules, nuxt.options.routeRules)
1744
+ });
1745
+ if (diagnostics.length)
1746
+ logger.warn(`Rendering configuration issues:
1747
+ ${diagnostics.map((d) => ` ${formatRenderingDiagnostic(d)}`).join("\n")}`);
1748
+ });
1749
+ }
1260
1750
  if (config.validateAppHead) {
1261
1751
  nuxt.hook("modules:done", () => {
1262
1752
  try {