@intlayer/engine 9.5.10 → 9.5.12

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 (266) hide show
  1. package/dist/assets/installSkills/skills/bundle-optimization.md +123 -0
  2. package/dist/assets/installSkills/skills/compat.md +0 -4
  3. package/dist/assets/installSkills/skills/content.md +8 -2
  4. package/dist/assets/installSkills/skills/next-js.md +2 -2
  5. package/dist/assets/installSkills/skills/usage.md +1 -1
  6. package/dist/cjs/build.cjs +5 -0
  7. package/dist/cjs/cli.cjs +1 -0
  8. package/dist/cjs/docReview/alignBlocks.cjs +53 -36
  9. package/dist/cjs/docReview/alignBlocks.cjs.map +1 -1
  10. package/dist/cjs/docReview/rebuildDocument.cjs +3 -1
  11. package/dist/cjs/docReview/rebuildDocument.cjs.map +1 -1
  12. package/dist/cjs/docReview/segmentDocument.cjs +8 -5
  13. package/dist/cjs/docReview/segmentDocument.cjs.map +1 -1
  14. package/dist/cjs/init/frameworkSetup/nextAppRouter/transforms.cjs +4 -1
  15. package/dist/cjs/init/frameworkSetup/nextAppRouter/transforms.cjs.map +1 -1
  16. package/dist/cjs/init/index.cjs +2 -12
  17. package/dist/cjs/init/index.cjs.map +1 -1
  18. package/dist/cjs/init/utils/backendPackages.cjs +15 -0
  19. package/dist/cjs/init/utils/backendPackages.cjs.map +1 -0
  20. package/dist/cjs/init/utils/index.cjs +2 -3
  21. package/dist/cjs/installMCP/installMCP.cjs +1 -1
  22. package/dist/cjs/installMCP/installMCP.cjs.map +1 -1
  23. package/dist/cjs/installSkills/index.cjs +2 -0
  24. package/dist/cjs/installSkills/index.cjs.map +1 -1
  25. package/dist/cjs/loadDictionaries/loadDictionaries.cjs +9 -8
  26. package/dist/cjs/loadDictionaries/loadDictionaries.cjs.map +1 -1
  27. package/dist/cjs/loadDictionaries/loadLocalDictionaries.cjs +3 -1
  28. package/dist/cjs/loadDictionaries/loadLocalDictionaries.cjs.map +1 -1
  29. package/dist/cjs/loadDictionaries/loadRemoteDictionaries.cjs +4 -6
  30. package/dist/cjs/loadDictionaries/loadRemoteDictionaries.cjs.map +1 -1
  31. package/dist/cjs/loadDictionaries/log.cjs +1 -1
  32. package/dist/cjs/loadDictionaries/log.cjs.map +1 -1
  33. package/dist/cjs/loadDictionaries/logTypeScriptErrors.cjs +19 -12
  34. package/dist/cjs/loadDictionaries/logTypeScriptErrors.cjs.map +1 -1
  35. package/dist/cjs/loadDictionaries/remoteDictionaryClient.cjs +18 -0
  36. package/dist/cjs/loadDictionaries/remoteDictionaryClient.cjs.map +1 -0
  37. package/dist/cjs/prepareIntlayerServer.cjs +132 -0
  38. package/dist/cjs/prepareIntlayerServer.cjs.map +1 -0
  39. package/dist/cjs/scan/analyzeBundleContent.cjs +26 -12
  40. package/dist/cjs/scan/analyzeBundleContent.cjs.map +1 -1
  41. package/dist/cjs/scan/calculateScore.cjs +4 -1
  42. package/dist/cjs/scan/calculateScore.cjs.map +1 -1
  43. package/dist/cjs/scan/checks.cjs +394 -221
  44. package/dist/cjs/scan/checks.cjs.map +1 -1
  45. package/dist/cjs/scan/detection/checkDetails.cjs +56 -0
  46. package/dist/cjs/scan/detection/checkDetails.cjs.map +1 -0
  47. package/dist/cjs/scan/detection/classifyInternalLinks.cjs +47 -0
  48. package/dist/cjs/scan/detection/classifyInternalLinks.cjs.map +1 -0
  49. package/dist/cjs/scan/detection/detectRoutingStrategy.cjs +143 -0
  50. package/dist/cjs/scan/detection/detectRoutingStrategy.cjs.map +1 -0
  51. package/dist/cjs/scan/detection/detectTechnologies.cjs +89 -0
  52. package/dist/cjs/scan/detection/detectTechnologies.cjs.map +1 -0
  53. package/dist/cjs/scan/detection/index.cjs +36 -0
  54. package/dist/cjs/scan/detection/localeCode.cjs +118 -0
  55. package/dist/cjs/scan/detection/localeCode.cjs.map +1 -0
  56. package/dist/cjs/scan/detection/localizedPages.cjs +48 -0
  57. package/dist/cjs/scan/detection/localizedPages.cjs.map +1 -0
  58. package/dist/cjs/scan/detection/technologySignatures.cjs +564 -0
  59. package/dist/cjs/scan/detection/technologySignatures.cjs.map +1 -0
  60. package/dist/cjs/scan/detection/url.cjs +53 -0
  61. package/dist/cjs/scan/detection/url.cjs.map +1 -0
  62. package/dist/cjs/scan/fetchText.cjs +56 -0
  63. package/dist/cjs/scan/fetchText.cjs.map +1 -0
  64. package/dist/cjs/scan/index.cjs +59 -3
  65. package/dist/cjs/scan/parseHtml.cjs +55 -10
  66. package/dist/cjs/scan/parseHtml.cjs.map +1 -1
  67. package/dist/cjs/scan/robots.cjs +65 -0
  68. package/dist/cjs/scan/robots.cjs.map +1 -0
  69. package/dist/cjs/scan/runScanChecks.cjs +89 -0
  70. package/dist/cjs/scan/runScanChecks.cjs.map +1 -0
  71. package/dist/cjs/scan/scanWebsite.cjs +58 -52
  72. package/dist/cjs/scan/scanWebsite.cjs.map +1 -1
  73. package/dist/cjs/scan/sitemap.cjs +109 -0
  74. package/dist/cjs/scan/sitemap.cjs.map +1 -0
  75. package/dist/cjs/utils/chunkJSON.cjs +24 -9
  76. package/dist/cjs/utils/chunkJSON.cjs.map +1 -1
  77. package/dist/cjs/utils/getChunk.cjs +2 -2
  78. package/dist/cjs/utils/getChunk.cjs.map +1 -1
  79. package/dist/cjs/utils/getProcessChainCommands.cjs +117 -0
  80. package/dist/cjs/utils/getProcessChainCommands.cjs.map +1 -0
  81. package/dist/cjs/utils/index.cjs +2 -0
  82. package/dist/cjs/utils/runParallel/index.cjs +1 -1
  83. package/dist/cjs/utils/runParallel/index.cjs.map +1 -1
  84. package/dist/cjs/utils/runParallel/pidTree.cjs +23 -11
  85. package/dist/cjs/utils/runParallel/pidTree.cjs.map +1 -1
  86. package/dist/cjs/utils/runParallel/ps.cjs +8 -3
  87. package/dist/cjs/utils/runParallel/ps.cjs.map +1 -1
  88. package/dist/cjs/utils/runParallel/runTask.cjs +2 -1
  89. package/dist/cjs/utils/runParallel/runTask.cjs.map +1 -1
  90. package/dist/cjs/utils/runParallel/wmic.cjs +8 -3
  91. package/dist/cjs/utils/runParallel/wmic.cjs.map +1 -1
  92. package/dist/cjs/utils/startContentWatcher.cjs +99 -0
  93. package/dist/cjs/utils/startContentWatcher.cjs.map +1 -0
  94. package/dist/cjs/watcher.cjs +2 -2
  95. package/dist/cjs/watcher.cjs.map +1 -1
  96. package/dist/cjs/writeContentDeclaration/detectExportedComponentName.cjs +7 -4
  97. package/dist/cjs/writeContentDeclaration/detectExportedComponentName.cjs.map +1 -1
  98. package/dist/cjs/writeContentDeclaration/writeMarkdownFile.cjs +2 -2
  99. package/dist/cjs/writeContentDeclaration/writeMarkdownFile.cjs.map +1 -1
  100. package/dist/cjs/writeJsonIfChanged.cjs.map +1 -1
  101. package/dist/esm/build.mjs +2 -1
  102. package/dist/esm/cli.mjs +2 -2
  103. package/dist/esm/docReview/alignBlocks.mjs +53 -36
  104. package/dist/esm/docReview/alignBlocks.mjs.map +1 -1
  105. package/dist/esm/docReview/rebuildDocument.mjs +3 -1
  106. package/dist/esm/docReview/rebuildDocument.mjs.map +1 -1
  107. package/dist/esm/docReview/segmentDocument.mjs +8 -5
  108. package/dist/esm/docReview/segmentDocument.mjs.map +1 -1
  109. package/dist/esm/init/frameworkSetup/nextAppRouter/transforms.mjs +4 -1
  110. package/dist/esm/init/frameworkSetup/nextAppRouter/transforms.mjs.map +1 -1
  111. package/dist/esm/init/index.mjs +1 -11
  112. package/dist/esm/init/index.mjs.map +1 -1
  113. package/dist/esm/init/utils/backendPackages.mjs +14 -0
  114. package/dist/esm/init/utils/backendPackages.mjs.map +1 -0
  115. package/dist/esm/init/utils/index.mjs +2 -2
  116. package/dist/esm/installMCP/installMCP.mjs +1 -1
  117. package/dist/esm/installMCP/installMCP.mjs.map +1 -1
  118. package/dist/esm/installSkills/index.mjs +2 -0
  119. package/dist/esm/installSkills/index.mjs.map +1 -1
  120. package/dist/esm/loadDictionaries/loadDictionaries.mjs +9 -8
  121. package/dist/esm/loadDictionaries/loadDictionaries.mjs.map +1 -1
  122. package/dist/esm/loadDictionaries/loadLocalDictionaries.mjs +3 -1
  123. package/dist/esm/loadDictionaries/loadLocalDictionaries.mjs.map +1 -1
  124. package/dist/esm/loadDictionaries/loadRemoteDictionaries.mjs +3 -5
  125. package/dist/esm/loadDictionaries/loadRemoteDictionaries.mjs.map +1 -1
  126. package/dist/esm/loadDictionaries/log.mjs +1 -1
  127. package/dist/esm/loadDictionaries/log.mjs.map +1 -1
  128. package/dist/esm/loadDictionaries/logTypeScriptErrors.mjs +19 -12
  129. package/dist/esm/loadDictionaries/logTypeScriptErrors.mjs.map +1 -1
  130. package/dist/esm/loadDictionaries/remoteDictionaryClient.mjs +16 -0
  131. package/dist/esm/loadDictionaries/remoteDictionaryClient.mjs.map +1 -0
  132. package/dist/esm/prepareIntlayerServer.mjs +128 -0
  133. package/dist/esm/prepareIntlayerServer.mjs.map +1 -0
  134. package/dist/esm/scan/analyzeBundleContent.mjs +26 -12
  135. package/dist/esm/scan/analyzeBundleContent.mjs.map +1 -1
  136. package/dist/esm/scan/calculateScore.mjs +4 -1
  137. package/dist/esm/scan/calculateScore.mjs.map +1 -1
  138. package/dist/esm/scan/checks.mjs +387 -220
  139. package/dist/esm/scan/checks.mjs.map +1 -1
  140. package/dist/esm/scan/detection/checkDetails.mjs +54 -0
  141. package/dist/esm/scan/detection/checkDetails.mjs.map +1 -0
  142. package/dist/esm/scan/detection/classifyInternalLinks.mjs +46 -0
  143. package/dist/esm/scan/detection/classifyInternalLinks.mjs.map +1 -0
  144. package/dist/esm/scan/detection/detectRoutingStrategy.mjs +141 -0
  145. package/dist/esm/scan/detection/detectRoutingStrategy.mjs.map +1 -0
  146. package/dist/esm/scan/detection/detectTechnologies.mjs +87 -0
  147. package/dist/esm/scan/detection/detectTechnologies.mjs.map +1 -0
  148. package/dist/esm/scan/detection/index.mjs +10 -0
  149. package/dist/esm/scan/detection/localeCode.mjs +108 -0
  150. package/dist/esm/scan/detection/localeCode.mjs.map +1 -0
  151. package/dist/esm/scan/detection/localizedPages.mjs +46 -0
  152. package/dist/esm/scan/detection/localizedPages.mjs.map +1 -0
  153. package/dist/esm/scan/detection/technologySignatures.mjs +563 -0
  154. package/dist/esm/scan/detection/technologySignatures.mjs.map +1 -0
  155. package/dist/esm/scan/detection/url.mjs +47 -0
  156. package/dist/esm/scan/detection/url.mjs.map +1 -0
  157. package/dist/esm/scan/fetchText.mjs +55 -0
  158. package/dist/esm/scan/fetchText.mjs.map +1 -0
  159. package/dist/esm/scan/index.mjs +15 -3
  160. package/dist/esm/scan/parseHtml.mjs +52 -11
  161. package/dist/esm/scan/parseHtml.mjs.map +1 -1
  162. package/dist/esm/scan/robots.mjs +63 -0
  163. package/dist/esm/scan/robots.mjs.map +1 -0
  164. package/dist/esm/scan/runScanChecks.mjs +88 -0
  165. package/dist/esm/scan/runScanChecks.mjs.map +1 -0
  166. package/dist/esm/scan/scanWebsite.mjs +59 -53
  167. package/dist/esm/scan/scanWebsite.mjs.map +1 -1
  168. package/dist/esm/scan/sitemap.mjs +105 -0
  169. package/dist/esm/scan/sitemap.mjs.map +1 -0
  170. package/dist/esm/utils/chunkJSON.mjs +24 -9
  171. package/dist/esm/utils/chunkJSON.mjs.map +1 -1
  172. package/dist/esm/utils/getChunk.mjs +2 -2
  173. package/dist/esm/utils/getChunk.mjs.map +1 -1
  174. package/dist/esm/utils/getProcessChainCommands.mjs +115 -0
  175. package/dist/esm/utils/getProcessChainCommands.mjs.map +1 -0
  176. package/dist/esm/utils/index.mjs +2 -1
  177. package/dist/esm/utils/runParallel/index.mjs +1 -1
  178. package/dist/esm/utils/runParallel/index.mjs.map +1 -1
  179. package/dist/esm/utils/runParallel/pidTree.mjs +23 -11
  180. package/dist/esm/utils/runParallel/pidTree.mjs.map +1 -1
  181. package/dist/esm/utils/runParallel/ps.mjs +8 -3
  182. package/dist/esm/utils/runParallel/ps.mjs.map +1 -1
  183. package/dist/esm/utils/runParallel/runTask.mjs +2 -1
  184. package/dist/esm/utils/runParallel/runTask.mjs.map +1 -1
  185. package/dist/esm/utils/runParallel/wmic.mjs +8 -3
  186. package/dist/esm/utils/runParallel/wmic.mjs.map +1 -1
  187. package/dist/esm/utils/startContentWatcher.mjs +98 -0
  188. package/dist/esm/utils/startContentWatcher.mjs.map +1 -0
  189. package/dist/esm/watcher.mjs +1 -1
  190. package/dist/esm/watcher.mjs.map +1 -1
  191. package/dist/esm/writeContentDeclaration/detectExportedComponentName.mjs +7 -4
  192. package/dist/esm/writeContentDeclaration/detectExportedComponentName.mjs.map +1 -1
  193. package/dist/esm/writeContentDeclaration/writeMarkdownFile.mjs +2 -2
  194. package/dist/esm/writeContentDeclaration/writeMarkdownFile.mjs.map +1 -1
  195. package/dist/esm/writeJsonIfChanged.mjs +1 -1
  196. package/dist/esm/writeJsonIfChanged.mjs.map +1 -1
  197. package/dist/types/build.d.ts +2 -1
  198. package/dist/types/cli.d.ts +2 -2
  199. package/dist/types/docReview/rebuildDocument.d.ts.map +1 -1
  200. package/dist/types/docReview/segmentDocument.d.ts.map +1 -1
  201. package/dist/types/init/frameworkSetup/nextAppRouter/transforms.d.ts.map +1 -1
  202. package/dist/types/init/index.d.ts +1 -1
  203. package/dist/types/init/index.d.ts.map +1 -1
  204. package/dist/types/init/utils/backendPackages.d.ts +5 -0
  205. package/dist/types/init/utils/backendPackages.d.ts.map +1 -0
  206. package/dist/types/init/utils/index.d.ts +2 -2
  207. package/dist/types/installSkills/index.d.ts +1 -0
  208. package/dist/types/installSkills/index.d.ts.map +1 -1
  209. package/dist/types/loadDictionaries/loadRemoteDictionaries.d.ts.map +1 -1
  210. package/dist/types/loadDictionaries/logTypeScriptErrors.d.ts.map +1 -1
  211. package/dist/types/loadDictionaries/remoteDictionaryClient.d.ts +14 -0
  212. package/dist/types/loadDictionaries/remoteDictionaryClient.d.ts.map +1 -0
  213. package/dist/types/prepareIntlayerServer.d.ts +67 -0
  214. package/dist/types/prepareIntlayerServer.d.ts.map +1 -0
  215. package/dist/types/scan/analyzeBundleContent.d.ts.map +1 -1
  216. package/dist/types/scan/calculateScore.d.ts +4 -1
  217. package/dist/types/scan/calculateScore.d.ts.map +1 -1
  218. package/dist/types/scan/checks.d.ts +114 -21
  219. package/dist/types/scan/checks.d.ts.map +1 -1
  220. package/dist/types/scan/detection/checkDetails.d.ts +29 -0
  221. package/dist/types/scan/detection/checkDetails.d.ts.map +1 -0
  222. package/dist/types/scan/detection/classifyInternalLinks.d.ts +44 -0
  223. package/dist/types/scan/detection/classifyInternalLinks.d.ts.map +1 -0
  224. package/dist/types/scan/detection/detectRoutingStrategy.d.ts +60 -0
  225. package/dist/types/scan/detection/detectRoutingStrategy.d.ts.map +1 -0
  226. package/dist/types/scan/detection/detectTechnologies.d.ts +60 -0
  227. package/dist/types/scan/detection/detectTechnologies.d.ts.map +1 -0
  228. package/dist/types/scan/detection/index.d.ts +9 -0
  229. package/dist/types/scan/detection/localeCode.d.ts +49 -0
  230. package/dist/types/scan/detection/localeCode.d.ts.map +1 -0
  231. package/dist/types/scan/detection/localizedPages.d.ts +40 -0
  232. package/dist/types/scan/detection/localizedPages.d.ts.map +1 -0
  233. package/dist/types/scan/detection/technologySignatures.d.ts +48 -0
  234. package/dist/types/scan/detection/technologySignatures.d.ts.map +1 -0
  235. package/dist/types/scan/detection/url.d.ts +27 -0
  236. package/dist/types/scan/detection/url.d.ts.map +1 -0
  237. package/dist/types/scan/fetchText.d.ts +30 -0
  238. package/dist/types/scan/fetchText.d.ts.map +1 -0
  239. package/dist/types/scan/index.d.ts +16 -4
  240. package/dist/types/scan/parseHtml.d.ts +17 -0
  241. package/dist/types/scan/parseHtml.d.ts.map +1 -1
  242. package/dist/types/scan/robots.d.ts +23 -0
  243. package/dist/types/scan/robots.d.ts.map +1 -0
  244. package/dist/types/scan/runScanChecks.d.ts +43 -0
  245. package/dist/types/scan/runScanChecks.d.ts.map +1 -0
  246. package/dist/types/scan/scanWebsite.d.ts.map +1 -1
  247. package/dist/types/scan/sitemap.d.ts +49 -0
  248. package/dist/types/scan/sitemap.d.ts.map +1 -0
  249. package/dist/types/scan/types.d.ts +15 -0
  250. package/dist/types/scan/types.d.ts.map +1 -1
  251. package/dist/types/utils/chunkJSON.d.ts.map +1 -1
  252. package/dist/types/utils/getProcessChainCommands.d.ts +35 -0
  253. package/dist/types/utils/getProcessChainCommands.d.ts.map +1 -0
  254. package/dist/types/utils/index.d.ts +2 -1
  255. package/dist/types/utils/runParallel/pidTree.d.ts.map +1 -1
  256. package/dist/types/utils/startContentWatcher.d.ts +40 -0
  257. package/dist/types/utils/startContentWatcher.d.ts.map +1 -0
  258. package/dist/types/watcher.d.ts.map +1 -1
  259. package/dist/types/writeContentDeclaration/writeMarkdownFile.d.ts.map +1 -1
  260. package/package.json +15 -7
  261. package/dist/cjs/init/utils/devScript.cjs +0 -57
  262. package/dist/cjs/init/utils/devScript.cjs.map +0 -1
  263. package/dist/esm/init/utils/devScript.mjs +0 -55
  264. package/dist/esm/init/utils/devScript.mjs.map +0 -1
  265. package/dist/types/init/utils/devScript.d.ts +0 -48
  266. package/dist/types/init/utils/devScript.d.ts.map +0 -1
@@ -1,265 +1,432 @@
1
- import { extractHreflangs, extractHtmlDir, extractHtmlLang, hasCanonical } from "./parseHtml.mjs";
1
+ import { fetchText } from "./fetchText.mjs";
2
+ import { isAbsoluteUrl, normalizeUrl, parseUrl } from "./detection/url.mjs";
3
+ import { extractHreflangs, extractHtmlLang } from "./parseHtml.mjs";
2
4
  import { analyzeBundleContent } from "./analyzeBundleContent.mjs";
3
- import { ALL_LOCALES } from "@intlayer/types/allLocales";
5
+ import { getLanguageCode, isRightToLeftLocale, isSameLanguage, isValidLocaleCode, isValidOpenGraphLocale, normalizeLocaleCode, toOpenGraphLocale } from "./detection/localeCode.mjs";
6
+ import { getUrlLocale } from "./detection/detectRoutingStrategy.mjs";
7
+ import { classifyInternalLinks } from "./detection/classifyInternalLinks.mjs";
8
+ import { isPathAllowedByRobots, parseRobots } from "./robots.mjs";
9
+ import { collectSitemapEntries, getDefaultSitemapUrls } from "./sitemap.mjs";
4
10
 
5
11
  //#region src/scan/checks.ts
12
+ /** Maximum number of links listed in a check's details. */
13
+ const MAX_LISTED_LINKS = 30;
14
+ /** Maximum number of hreflang alternates fetched for the reciprocity check. */
15
+ const MAX_FETCHED_ALTERNATES = 8;
16
+ /** Statuses meaning the site refuses automated requests, not a broken page. */
17
+ const BOT_BLOCKING_STATUSES = /* @__PURE__ */ new Set([
18
+ 401,
19
+ 403,
20
+ 429
21
+ ]);
6
22
  /** Format a byte count as a human-readable size. */
7
23
  const formatSize = (bytes) => {
8
24
  if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`;
9
25
  if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB`;
10
26
  return `${bytes} B`;
11
27
  };
28
+ /** Build a URL-scoped check type (`url_htmlLang\https://…`). */
29
+ const urlCheckType = (checkName, targetUrl) => `${checkName}\\${targetUrl}`;
30
+ /** Emit an event whose details are attached to its own status. */
31
+ const emitCheck = (emit, type, status, details) => emit({
32
+ type,
33
+ status,
34
+ details: details === void 0 ? void 0 : { [status]: details }
35
+ });
36
+ /** Render an anchor as an HTML line for the details popover. */
37
+ const formatAnchor = ({ href, text }) => `<a href="${href}">${text.slice(0, 60) || "Link"}</a>`;
38
+ /** `url_htmlLang`: `<html lang>` is present and a valid BCP 47 tag. */
39
+ const checkHtmlLang = ({ targetUrl, langTag }, emit) => {
40
+ const type = urlCheckType("url_htmlLang", targetUrl);
41
+ if (!langTag) emitCheck(emit, type, "error", "Missing html lang attribute");
42
+ else if (!isValidLocaleCode(langTag)) emitCheck(emit, type, "error", `"${langTag}" is not a valid BCP 47 language tag (expected e.g. "en" or "en-US")`);
43
+ else emitCheck(emit, type, "success", langTag);
44
+ };
12
45
  /**
13
- * Check the `<html>` element attributes (`lang`, `dir`) and the resulting
14
- * current-locale signal. Returns the detected language tag.
46
+ * `url_htmlDir`: the text direction matches the language. `dir` is only
47
+ * required for right-to-left languages, `ltr` being the browser default.
15
48
  */
16
- const checkHtmlAttributes = (html, targetUrl, events) => {
17
- const langTag = extractHtmlLang(html);
18
- const dirTag = extractHtmlDir(html);
19
- events.push({
20
- type: `url_htmlLang\\${targetUrl}`,
21
- status: langTag ? "success" : "error",
22
- details: {
23
- success: langTag,
24
- error: langTag ? void 0 : "Missing html lang attribute"
25
- }
26
- });
27
- events.push({
28
- type: `url_currentLocale\\${targetUrl}`,
29
- status: langTag ? "success" : "warning",
30
- details: {
31
- success: langTag,
32
- warning: langTag ? void 0 : "No locale detected"
33
- }
34
- });
35
- events.push({
36
- type: `url_htmlDir\\${targetUrl}`,
37
- status: dirTag ? "success" : "warning",
38
- details: {
39
- success: dirTag,
40
- warning: dirTag ? void 0 : "Missing html dir attribute"
41
- }
42
- });
43
- return { langTag };
49
+ const checkHtmlDir = ({ targetUrl, langTag, dirTag }, emit) => {
50
+ const type = urlCheckType("url_htmlDir", targetUrl);
51
+ const direction = dirTag?.toLowerCase();
52
+ if (!langTag) {
53
+ emitCheck(emit, type, "warning", "Cannot verify the text direction: html lang is missing");
54
+ return;
55
+ }
56
+ const expectedDirection = isRightToLeftLocale(langTag) ? "rtl" : "ltr";
57
+ if (direction === expectedDirection || !direction && expectedDirection === "ltr") {
58
+ emitCheck(emit, type, "success", direction ?? "ltr (browser default)");
59
+ return;
60
+ }
61
+ emitCheck(emit, type, "error", direction ? `dir="${direction}" does not match the ${expectedDirection.toUpperCase()} language "${langTag}"` : `Missing dir="rtl" for the right-to-left language "${langTag}"`);
44
62
  };
45
- /** Check the presence of a canonical link. */
46
- const checkCanonical = (html, targetUrl, events) => {
47
- const present = hasCanonical(html);
48
- events.push({
49
- type: `url_hasCanonical\\${targetUrl}`,
50
- status: present ? "success" : "warning",
51
- details: { warning: present ? void 0 : "Missing canonical link" }
63
+ /**
64
+ * `url_currentLocale`: the locale announced by `<html lang>`, the URL and the
65
+ * self hreflang entry agree.
66
+ */
67
+ const checkLocaleConsistency = ({ targetUrl, langTag, hreflangs, routing }, emit) => {
68
+ const type = urlCheckType("url_currentLocale", targetUrl);
69
+ const normalizedTargetUrl = normalizeUrl(targetUrl);
70
+ const selfHreflang = hreflangs.find(({ hreflang, href }) => hreflang.toLowerCase() !== "x-default" && normalizeUrl(href, targetUrl) === normalizedTargetUrl)?.hreflang;
71
+ const signals = {
72
+ htmlLang: langTag,
73
+ urlLocale: routing.urlLocale,
74
+ selfHreflang
75
+ };
76
+ const definedLocales = Object.values(signals).filter((locale) => Boolean(locale));
77
+ if (definedLocales.length === 0) {
78
+ emitCheck(emit, type, "warning", "No locale detected on the page");
79
+ return;
80
+ }
81
+ const firstLocale = definedLocales[0];
82
+ const isConsistent = definedLocales.every((locale) => isSameLanguage(locale, firstLocale));
83
+ emitCheck(emit, type, isConsistent ? "success" : "error", isConsistent ? signals : {
84
+ message: "The page announces different locales in html lang, its URL and its hreflang entry",
85
+ ...signals
52
86
  });
53
87
  };
88
+ /** Describe an invalid Open Graph locale, with the value to use instead. */
89
+ const formatInvalidOpenGraphLocale = (localeCode, langTag, property) => {
90
+ const suggestion = toOpenGraphLocale(localeCode, langTag);
91
+ return `${property} "${localeCode}" is not a valid Open Graph locale (expected language_TERRITORY${suggestion ? `, e.g. "${suggestion}"` : ""}): crawlers ignore it and fall back to en_US`;
92
+ };
54
93
  /**
55
- * Check the page's hreflang structure and collect discovered locales into
56
- * `localesSet`.
94
+ * `url_ogLocale`: `og:locale` is set, uses the Open Graph `language_TERRITORY`
95
+ * format (`en_GB`, not `en` nor `en-GB`) and matches `<html lang>`.
96
+ * `og:locale:alternate` values must use the same format.
57
97
  */
58
- const checkLinguisticStructure = (html, targetUrl, localesSet, events) => {
59
- const langTag = extractHtmlLang(html);
60
- if (langTag) localesSet.add(langTag);
61
- const hreflangs = extractHreflangs(html);
62
- for (const { hreflang } of hreflangs) if (hreflang !== "x-default") localesSet.add(hreflang);
63
- const hasXDefault = hreflangs.some((h) => h.hreflang === "x-default");
64
- events.push({
65
- type: `url_hreflang\\${targetUrl}`,
66
- status: hreflangs.length > 0 ? "success" : "warning",
67
- details: {
68
- success: hreflangs.length > 0 ? hreflangs : void 0,
69
- warning: hreflangs.length === 0 ? "No hreflang tags found" : void 0
70
- }
71
- });
72
- events.push({
73
- type: `url_hasXDefault\\${targetUrl}`,
74
- status: hasXDefault ? "success" : "error",
75
- details: { error: hasXDefault ? void 0 : "Missing x-default hreflang link" }
98
+ const checkOgLocale = ({ targetUrl, ogLocale, ogLocaleAlternates = [], langTag }, emit) => {
99
+ const type = urlCheckType("url_ogLocale", targetUrl);
100
+ if (!ogLocale) {
101
+ emitCheck(emit, type, "warning", "Missing <meta property=\"og:locale\">: social previews default to en_US");
102
+ return;
103
+ }
104
+ if (!isValidOpenGraphLocale(ogLocale)) {
105
+ emitCheck(emit, type, "error", formatInvalidOpenGraphLocale(ogLocale, langTag, "og:locale"));
106
+ return;
107
+ }
108
+ if (langTag && !isSameLanguage(ogLocale, langTag)) {
109
+ emitCheck(emit, type, "warning", `og:locale "${ogLocale}" does not match html lang "${langTag}"`);
110
+ return;
111
+ }
112
+ const invalidAlternates = ogLocaleAlternates.filter((alternate) => !isValidOpenGraphLocale(alternate));
113
+ if (invalidAlternates.length > 0) {
114
+ emitCheck(emit, type, "warning", invalidAlternates.map((alternate) => formatInvalidOpenGraphLocale(alternate, void 0, "og:locale:alternate")).join("\n"));
115
+ return;
116
+ }
117
+ emitCheck(emit, type, "success", ogLocale);
118
+ };
119
+ /**
120
+ * `url_hasCanonical`: a canonical link exists and does not point to another
121
+ * locale version (which would de-index the current one).
122
+ */
123
+ const checkCanonical = ({ targetUrl, canonicalHref, hreflangs, routing, pageLocale }, emit) => {
124
+ const type = urlCheckType("url_hasCanonical", targetUrl);
125
+ if (canonicalHref === void 0 || canonicalHref === "") {
126
+ emitCheck(emit, type, "warning", "Missing canonical link");
127
+ return;
128
+ }
129
+ const canonicalUrl = normalizeUrl(canonicalHref, targetUrl);
130
+ const otherLocaleAlternate = hreflangs.find(({ hreflang, href }) => hreflang.toLowerCase() !== "x-default" && normalizeUrl(href, targetUrl) === canonicalUrl && !isSameLanguage(hreflang, pageLocale));
131
+ const canonicalLocale = getUrlLocale(canonicalUrl, routing);
132
+ if (otherLocaleAlternate || canonicalLocale && pageLocale && !isSameLanguage(canonicalLocale, pageLocale)) {
133
+ emitCheck(emit, type, "error", {
134
+ message: `The canonical URL points to the "${otherLocaleAlternate?.hreflang ?? canonicalLocale}" version instead of this "${pageLocale}" page: search engines will drop this page from their index`,
135
+ canonical: canonicalUrl
136
+ });
137
+ return;
138
+ }
139
+ emitCheck(emit, type, "success", canonicalUrl === normalizeUrl(targetUrl) ? canonicalUrl : {
140
+ message: "Canonical points to another URL of the same locale",
141
+ canonical: canonicalUrl
76
142
  });
77
143
  };
78
- const normalizeHost = (host) => host.startsWith("www.") ? host.slice(4) : host;
79
- const localeValues = Object.values(ALL_LOCALES);
80
144
  /**
81
- * Check whether internal links carry a locale segment, mirroring the hosted
82
- * audit's URL-structure analysis but operating on parsed anchors instead of a
83
- * live DOM.
145
+ * `url_hreflang`: hreflang tags exist, use valid codes and absolute URLs, have
146
+ * no duplicates, and include a self reference.
84
147
  */
85
- const checkUrlStructure = (anchors, origin, targetUrl, events) => {
86
- const targetHostname = normalizeHost(new URL(origin).hostname);
87
- let localizedCount = 0;
88
- let totalInternalCount = 0;
89
- const nonLocalizedLinks = [];
90
- for (const { href } of anchors) {
91
- if (!href || href.startsWith("#") || href.startsWith("javascript:")) continue;
92
- try {
93
- const url = new URL(href, origin);
94
- if (normalizeHost(url.hostname) !== targetHostname) continue;
95
- totalInternalCount++;
96
- const path = url.pathname.toLowerCase();
97
- const hostname = url.hostname.toLowerCase();
98
- const hasLocaleInPath = localeValues.some((locale) => {
99
- const l = locale.toLowerCase();
100
- return path === `/${l}` || path.includes(`/${l}/`);
101
- });
102
- const hasLocaleInSubdomain = localeValues.some((locale) => {
103
- const l = locale.toLowerCase();
104
- return hostname.startsWith(`${l}.`) || hostname.includes(`.${l}.`);
105
- });
106
- if (hasLocaleInPath || hasLocaleInSubdomain) localizedCount++;
107
- else nonLocalizedLinks.push(href);
108
- } catch {}
148
+ const checkHreflang = ({ targetUrl, hreflangs, canonicalHref }, emit) => {
149
+ const type = urlCheckType("url_hreflang", targetUrl);
150
+ if (hreflangs.length === 0) {
151
+ emitCheck(emit, type, "warning", "No hreflang tags found");
152
+ return;
109
153
  }
110
- const hasLocalizedLinks = localizedCount > 0;
111
- const allAnchorsLocalized = totalInternalCount === 0 || localizedCount === totalInternalCount;
112
- events.push({
113
- type: `url_hasLocalizedLinks\\${targetUrl}`,
114
- status: hasLocalizedLinks ? "success" : "warning",
115
- details: {
116
- success: hasLocalizedLinks ? `${localizedCount} localized links found out of ${totalInternalCount} internal links` : void 0,
117
- warning: hasLocalizedLinks ? void 0 : "No localized links found"
118
- }
154
+ const issues = [];
155
+ const invalidCodes = hreflangs.map(({ hreflang }) => hreflang).filter((hreflang) => !isValidLocaleCode(hreflang));
156
+ if (invalidCodes.length > 0) issues.push(`Invalid hreflang codes: ${invalidCodes.join(", ")}`);
157
+ const ukCodes = hreflangs.map(({ hreflang }) => hreflang).filter((hreflang) => /-uk$/i.test(hreflang));
158
+ if (ukCodes.length > 0) issues.push(`"${ukCodes.join(", ")}": the United Kingdom region code is "GB" (e.g. en-GB)`);
159
+ const relativeHrefs = hreflangs.filter(({ href }) => !isAbsoluteUrl(href));
160
+ if (relativeHrefs.length > 0) issues.push(`hreflang URLs must be absolute (https://…), search engines ignore: ${relativeHrefs.map(({ hreflang, href }) => `${hreflang} → ${href}`).join(", ")}`);
161
+ const urlsByCode = /* @__PURE__ */ new Map();
162
+ for (const { hreflang, href } of hreflangs) {
163
+ const code = normalizeLocaleCode(hreflang);
164
+ const urls = urlsByCode.get(code) ?? /* @__PURE__ */ new Set();
165
+ urls.add(normalizeUrl(href, targetUrl));
166
+ urlsByCode.set(code, urls);
167
+ }
168
+ const conflictingCodes = [...urlsByCode.entries()].filter(([, urls]) => urls.size > 1).map(([code]) => code);
169
+ if (conflictingCodes.length > 0) issues.push(`Same hreflang declared with different URLs: ${conflictingCodes.join(", ")}`);
170
+ const selfUrls = /* @__PURE__ */ new Set([normalizeUrl(targetUrl)]);
171
+ if (canonicalHref) selfUrls.add(normalizeUrl(canonicalHref, targetUrl));
172
+ if (!hreflangs.some(({ hreflang, href }) => hreflang.toLowerCase() !== "x-default" && selfUrls.has(normalizeUrl(href, targetUrl)))) issues.push("No hreflang entry points to the page itself (self reference)");
173
+ const status = relativeHrefs.length > 0 ? "error" : issues.length > 0 ? "warning" : "success";
174
+ emitCheck(emit, type, status, issues.length > 0 ? {
175
+ issues,
176
+ hreflangs
177
+ } : hreflangs);
178
+ };
179
+ /** `url_hasXDefault`: an `x-default` hreflang is declared. */
180
+ const checkXDefault = ({ targetUrl, hreflangs }, emit) => {
181
+ const type = urlCheckType("url_hasXDefault", targetUrl);
182
+ const xDefault = hreflangs.find(({ hreflang }) => hreflang.toLowerCase() === "x-default");
183
+ if (xDefault) emitCheck(emit, type, "success", xDefault.href);
184
+ else if (hreflangs.length === 0) emitCheck(emit, type, "warning", "No hreflang tags, so no x-default either");
185
+ else emitCheck(emit, type, "error", "Missing x-default hreflang link");
186
+ };
187
+ /** Fetch an alternate and check it forms a valid hreflang pair with the page. */
188
+ const verifyAlternate = async (alternateUrl, hreflang, selfUrls, fetchOptions) => {
189
+ const response = await fetchText(alternateUrl, fetchOptions);
190
+ const verification = {
191
+ url: alternateUrl,
192
+ hreflang
193
+ };
194
+ if (!response || BOT_BLOCKING_STATUSES.has(response.status)) return {
195
+ ...verification,
196
+ outcome: "unverifiable",
197
+ issues: []
198
+ };
199
+ if (!response.ok) return {
200
+ ...verification,
201
+ outcome: "broken",
202
+ issues: [`HTTP ${response.status}`]
203
+ };
204
+ const issues = [];
205
+ if (response.redirected) issues.push(`redirects to ${response.finalUrl}`);
206
+ if (!extractHreflangs(response.text).some(({ href }) => selfUrls.has(normalizeUrl(href, response.finalUrl)))) issues.push("no hreflang link back to this page");
207
+ const alternateLang = extractHtmlLang(response.text);
208
+ if (alternateLang && !isSameLanguage(alternateLang, hreflang)) issues.push(`html lang is "${alternateLang}"`);
209
+ return {
210
+ ...verification,
211
+ outcome: issues.length > 0 ? "invalid" : "valid",
212
+ issues
213
+ };
214
+ };
215
+ /**
216
+ * `url_hreflangReciprocal`: every alternate answers with a 200, is not
217
+ * redirected, links back to this page and declares the expected language.
218
+ * Search engines ignore hreflang pairs without a return link.
219
+ */
220
+ const checkHreflangReciprocity = async ({ targetUrl, hreflangs, canonicalHref }, fetchOptions, emit) => {
221
+ const type = urlCheckType("url_hreflangReciprocal", targetUrl);
222
+ const selfUrls = /* @__PURE__ */ new Set([normalizeUrl(targetUrl)]);
223
+ if (canonicalHref) selfUrls.add(normalizeUrl(canonicalHref, targetUrl));
224
+ const alternatesByUrl = /* @__PURE__ */ new Map();
225
+ for (const { hreflang, href } of hreflangs) {
226
+ if (hreflang.toLowerCase() === "x-default") continue;
227
+ const absoluteUrl = parseUrl(href, targetUrl)?.href;
228
+ if (!absoluteUrl || selfUrls.has(normalizeUrl(absoluteUrl))) continue;
229
+ if (!alternatesByUrl.has(absoluteUrl)) alternatesByUrl.set(absoluteUrl, hreflang);
230
+ }
231
+ if (alternatesByUrl.size === 0) return;
232
+ const verifications = await Promise.all([...alternatesByUrl.entries()].slice(0, MAX_FETCHED_ALTERNATES).map(([alternateUrl, hreflang]) => verifyAlternate(alternateUrl, hreflang, selfUrls, fetchOptions)));
233
+ const unverifiableUrls = verifications.filter(({ outcome }) => outcome === "unverifiable").map(({ url }) => url);
234
+ const failingVerifications = verifications.filter(({ outcome }) => outcome === "broken" || outcome === "invalid");
235
+ const verifiedCount = verifications.length - unverifiableUrls.length;
236
+ if (verifiedCount === 0) {
237
+ emitCheck(emit, type, "warning", {
238
+ message: "The alternates could not be verified: they are unreachable or block automated requests",
239
+ urls: unverifiableUrls
240
+ });
241
+ return;
242
+ }
243
+ if (failingVerifications.length === 0) {
244
+ emitCheck(emit, type, "success", `${verifiedCount} alternates checked, all reciprocal`);
245
+ return;
246
+ }
247
+ emitCheck(emit, type, failingVerifications.some(({ outcome }) => outcome === "broken") ? "error" : "warning", {
248
+ message: `${failingVerifications.length} of ${verifiedCount} checked hreflang alternates are not valid return pairs`,
249
+ alternates: failingVerifications.map(({ hreflang, url, issues }) => `${hreflang} ${url}: ${issues.join(", ")}`),
250
+ unverifiableUrls
119
251
  });
120
- events.push({
121
- type: `url_allAnchorsLocalized\\${targetUrl}`,
122
- status: allAnchorsLocalized ? "success" : "warning",
123
- details: { warning: allAnchorsLocalized ? void 0 : {
124
- message: "Some internal links are not localized",
125
- links: nonLocalizedLinks
126
- } }
252
+ };
253
+ /**
254
+ * Link checks, interpreted through the detected routing strategy (so an
255
+ * unprefixed link is correct on the default locale of a `prefix-no-default`
256
+ * site, and `?lang=` links are understood on `search-params` sites):
257
+ *
258
+ * - `url_hasLocalizedLinks`: internal links keep the page locale
259
+ * - `url_allAnchorsLocalized`: no internal link loses or switches the locale
260
+ * - `url_hasLangSelector`: crawlable links to the other locale versions exist
261
+ */
262
+ const checkInternalLinks = (signals, emit) => {
263
+ const { targetUrl, routing, pageLocale } = signals;
264
+ const { internalLinks, sameLocaleLinks, otherLocaleLinks, unlocalizedLinks, switcherLinks } = classifyInternalLinks(signals);
265
+ const listLinks = (links) => links.slice(0, MAX_LISTED_LINKS).map(formatAnchor);
266
+ const localizedLinksType = urlCheckType("url_hasLocalizedLinks", targetUrl);
267
+ const isLocaleInUrl = routing.strategy !== "no-prefix" && routing.strategy !== "unknown";
268
+ if (internalLinks.length === 0) emitCheck(emit, localizedLinksType, "warning", {
269
+ message: "No internal links found on the page",
270
+ links: listLinks(switcherLinks)
271
+ });
272
+ else if (!isLocaleInUrl || !pageLocale) emitCheck(emit, localizedLinksType, "warning", {
273
+ message: `The locale is not reflected in the URLs (routing: ${routing.strategy}). Each locale needs its own crawlable URL to be indexed.`,
274
+ links: listLinks(internalLinks)
275
+ });
276
+ else if (sameLocaleLinks.length === 0) emitCheck(emit, localizedLinksType, "warning", {
277
+ message: `None of the ${internalLinks.length} internal links points to the "${pageLocale}" version (routing: ${routing.strategy})`,
278
+ links: listLinks(internalLinks)
279
+ });
280
+ else emitCheck(emit, localizedLinksType, "success", {
281
+ message: `${sameLocaleLinks.length} of ${internalLinks.length} internal links point to the "${pageLocale}" version (routing: ${routing.strategy})`,
282
+ links: listLinks(sameLocaleLinks)
127
283
  });
284
+ if (isLocaleInUrl && pageLocale && internalLinks.length > 0) {
285
+ const brokenLinks = [...otherLocaleLinks, ...unlocalizedLinks];
286
+ emitCheck(emit, urlCheckType("url_allAnchorsLocalized", targetUrl), brokenLinks.length === 0 ? "success" : "warning", brokenLinks.length === 0 ? `All ${internalLinks.length} internal links keep the "${pageLocale}" locale` : {
287
+ message: `${brokenLinks.length} internal links leave the "${pageLocale}" locale (${otherLocaleLinks.length} to another locale, ${unlocalizedLinks.length} without locale)`,
288
+ links: listLinks(brokenLinks)
289
+ });
290
+ }
291
+ if (routing.locales.length > 1) emitCheck(emit, urlCheckType("url_hasLangSelector", targetUrl), switcherLinks.length > 0 ? "success" : "warning", switcherLinks.length > 0 ? {
292
+ message: `${switcherLinks.length} crawlable links to other locale versions`,
293
+ links: listLinks(switcherLinks)
294
+ } : "No crawlable <a href> to the other locale versions: search engines only discover them through hreflang");
128
295
  };
296
+ const getFilename = (url) => url.split("/").pop()?.split("?")[0] ?? url;
297
+ const formatChunk = (chunk) => ({
298
+ filename: getFilename(chunk.url),
299
+ url: chunk.url,
300
+ fileSize: formatSize(chunk.fileSize),
301
+ totalLocaleSize: formatSize(chunk.totalLocaleSize),
302
+ usedLocaleSize: formatSize(chunk.usedLocaleSize),
303
+ unusedLocaleSize: formatSize(chunk.unusedLocaleSize),
304
+ dictionariesFound: chunk.dictionariesFound,
305
+ unusedPercent: `${chunk.unusedPercent}%`
306
+ });
307
+ /** Human-readable version of a {@link BundleContentAnalysis}, for display. */
308
+ const formatBundleAnalysis = (analysis) => ({
309
+ currentLocale: analysis.currentLocale,
310
+ totalPageSize: formatSize(analysis.totalPageSize),
311
+ renderedContentSize: formatSize(analysis.renderedContentSize),
312
+ contentSize: formatSize(analysis.contentSize),
313
+ totalLocaleSize: formatSize(analysis.totalLocaleSize),
314
+ totalUnusedLocaleSize: formatSize(analysis.totalUnusedLocaleSize),
315
+ unusedPercentOfLocale: `${analysis.unusedPercentOfLocale}%`,
316
+ mainBundleChunks: analysis.mainBundleChunks.map(formatChunk),
317
+ lazyBundleChunks: analysis.lazyBundleChunks.map(formatChunk)
318
+ });
129
319
  /**
130
- * Analyze the JavaScript bundles for unused locale content and emit the
131
- * corresponding event. Returns the analysis so callers can report on it.
320
+ * `url_unusedBundleContent`: share of other-locale translations shipped in the
321
+ * eagerly loaded bundles. Returns the raw analysis for reporting.
132
322
  */
133
- const checkBundleContent = (chunks, html, currentLocale, targetUrl, totalPageSize, events) => {
323
+ const checkBundleContent = (chunks, html, currentLocale, targetUrl, totalPageSize, emit) => {
324
+ const type = urlCheckType("url_unusedBundleContent", targetUrl);
134
325
  if (!currentLocale) {
135
- events.push({
136
- type: `url_unusedBundleContent\\${targetUrl}`,
137
- status: "warning",
138
- details: { warning: "Cannot analyse bundle content: page locale not detected" }
139
- });
326
+ emitCheck(emit, type, "warning", "Cannot analyse bundle content: page locale not detected");
140
327
  return;
141
328
  }
142
329
  const analysis = analyzeBundleContent(chunks, html, currentLocale, totalPageSize);
143
- const mainBundleMaxUnused = analysis.mainBundleChunks.reduce((max, c) => Math.max(max, c.unusedPercent), 0);
144
- const status = mainBundleMaxUnused === 0 ? "success" : mainBundleMaxUnused <= 30 ? "warning" : "error";
145
- events.push({
146
- type: `url_unusedBundleContent\\${targetUrl}`,
147
- status,
148
- details: { [status]: analysis }
149
- });
330
+ const mainBundleMaxUnused = analysis.mainBundleChunks.reduce((max, chunk) => Math.max(max, chunk.unusedPercent), 0);
331
+ emitCheck(emit, type, mainBundleMaxUnused === 0 ? "success" : mainBundleMaxUnused <= 30 ? "warning" : "error", formatBundleAnalysis(analysis));
150
332
  return analysis;
151
333
  };
152
- /** Fetch and check `robots.txt`, emitting robots-related events. */
153
- const checkRobots = async (origin, discoveredLocales, userAgent, events) => {
154
- let robotsPresent = false;
155
- let noLocalizedUrlsForgotten = true;
156
- const errors = [];
157
- try {
158
- const response = await fetch(`${origin}/robots.txt`, { headers: { "User-Agent": userAgent } });
159
- if (response.ok) {
160
- robotsPresent = true;
161
- const content = await response.text();
162
- if (content && discoveredLocales.size > 0) {
163
- const disallowedPaths = content.split("\n").map((line) => line.trim().toLowerCase()).filter((line) => line.startsWith("disallow:")).map((line) => line.slice(9).trim());
164
- for (const locale of discoveredLocales) for (const path of disallowedPaths) if (path === `/${locale}` || path === `/${locale}/`) {
165
- noLocalizedUrlsForgotten = false;
166
- errors.push(`Locale path "${locale}" appears to be blocked in robots.txt: ${path}`);
167
- }
168
- }
169
- }
170
- } catch (error) {
171
- errors.push(`Failed to fetch robots.txt: ${error instanceof Error ? error.message : "Unknown error"}`);
334
+ /**
335
+ * Fetch `robots.txt` and check that neither the site nor its localized URLs
336
+ * are blocked for Googlebot.
337
+ *
338
+ * - `robots_robotsPresent`
339
+ * - `robots_noLocalizedUrlsForgotten`
340
+ *
341
+ * @param localizedUrls - URLs of every locale version (hreflang alternates).
342
+ * @returns The sitemaps declared in `robots.txt`.
343
+ */
344
+ const checkRobots = async (origin, localizedUrls, fetchOptions, emit) => {
345
+ const response = await fetchText(`${origin}/robots.txt`, fetchOptions);
346
+ if (!response?.ok) {
347
+ emitCheck(emit, "robots_robotsPresent", "warning", response ? `No robots.txt found (HTTP ${response.status})` : "robots.txt is unreachable");
348
+ return [];
172
349
  }
173
- events.push({
174
- type: "robots_robotsPresent",
175
- status: robotsPresent ? "success" : "warning",
176
- details: {
177
- warning: robotsPresent ? void 0 : "No robots.txt found",
178
- error: errors.length > 0 ? errors : void 0
179
- }
350
+ const robots = parseRobots(response.text);
351
+ emitCheck(emit, "robots_robotsPresent", "success", {
352
+ disallow: robots.disallowedPaths,
353
+ sitemaps: robots.sitemapUrls
180
354
  });
181
- if (robotsPresent) events.push({
182
- type: "robots_noLocalizedUrlsForgotten",
183
- status: noLocalizedUrlsForgotten ? "success" : "error",
184
- details: { error: noLocalizedUrlsForgotten ? void 0 : errors }
355
+ const blockedUrls = [origin, ...localizedUrls].filter((url) => {
356
+ const parsedUrl = parseUrl(url);
357
+ return parsedUrl?.origin === origin && !isPathAllowedByRobots(`${parsedUrl.pathname}${parsedUrl.search}`, robots);
185
358
  });
359
+ const isSiteBlocked = !isPathAllowedByRobots("/", robots);
360
+ emitCheck(emit, "robots_noLocalizedUrlsForgotten", blockedUrls.length === 0 ? "success" : "error", blockedUrls.length === 0 ? true : isSiteBlocked ? "robots.txt blocks the whole site for Googlebot (Disallow: /)" : {
361
+ message: "Localized URLs blocked by robots.txt",
362
+ urls: [...new Set(blockedUrls)]
363
+ });
364
+ return robots.sitemapUrls;
186
365
  };
187
- /** Fetch and check `sitemap.xml`, emitting sitemap-related events. */
188
- const checkSitemap = async (origin, discoveredLocales, userAgent, events) => {
189
- let sitemapPresent = false;
190
- let hasXDefault = false;
366
+ /**
367
+ * Read the sitemaps (declared in `robots.txt`, else the default locations,
368
+ * following sitemap indexes) and check their locale coverage.
369
+ *
370
+ * - `sitemap_sitemapPresent`
371
+ * - `sitemap_noLocalizedUrlsForgotten`: every locale is listed, and entries
372
+ * declaring alternates declare all of them
373
+ * - `sitemap_hasAlternates` / `sitemap_hasXDefault`
374
+ */
375
+ const checkSitemap = async (origin, declaredSitemapUrls, routing, fetchOptions, emit) => {
376
+ const { fetchedSitemapUrls, entries, isTruncated } = await collectSitemapEntries(declaredSitemapUrls.length > 0 ? declaredSitemapUrls : getDefaultSitemapUrls(origin), fetchOptions);
377
+ if (fetchedSitemapUrls.length === 0) {
378
+ emitCheck(emit, "sitemap_sitemapPresent", "warning", declaredSitemapUrls.length > 0 ? `Sitemaps declared in robots.txt are unreachable: ${declaredSitemapUrls.join(", ")}` : "No sitemap found (robots.txt Sitemap directive, /sitemap.xml, /sitemap_index.xml)");
379
+ return;
380
+ }
381
+ emitCheck(emit, "sitemap_sitemapPresent", "success", {
382
+ sitemaps: fetchedSitemapUrls,
383
+ urlCount: entries.length,
384
+ isTruncated
385
+ });
386
+ const knownLocales = routing.locales;
387
+ const foundLanguages = /* @__PURE__ */ new Set();
388
+ const entriesWithoutSelfReference = [];
389
+ const relativeAlternates = [];
390
+ let partiallyTranslatedCount = 0;
191
391
  let hasAlternates = false;
192
- let noLocalizedUrlsForgotten = true;
193
- const errors = [];
194
- try {
195
- const response = await fetch(`${origin}/sitemap.xml`, { headers: { "User-Agent": userAgent } });
196
- if (response.ok) {
197
- sitemapPresent = true;
198
- const content = await response.text();
199
- const hreflangs = (content.match(/hreflang\s*=\s*"([^"]+)"/gi) ?? []).map((m) => m.replace(/hreflang\s*=\s*"/i, "").replace(/"$/, ""));
200
- hasAlternates = hreflangs.length > 0;
201
- hasXDefault = hreflangs.includes("x-default");
202
- if (discoveredLocales.size > 0) {
203
- const urlBlocks = content.match(/<url\b[\s\S]*?<\/url>/gi) ?? [];
204
- const allFoundLocales = /* @__PURE__ */ new Set();
205
- let anyUrlMissingLocale = false;
206
- for (const block of urlBlocks) {
207
- const localesInUrl = /* @__PURE__ */ new Set();
208
- for (const hreflang of block.match(/hreflang\s*=\s*"([^"]+)"/gi) ?? []) {
209
- const value = hreflang.replace(/hreflang\s*=\s*"/i, "").replace(/"$/, "");
210
- if (value !== "x-default") {
211
- localesInUrl.add(value);
212
- allFoundLocales.add(value);
213
- }
214
- }
215
- const loc = block.match(/<loc>([\s\S]*?)<\/loc>/i)?.[1]?.trim();
216
- if (loc) try {
217
- const firstSegment = new URL(loc).pathname.split("/").filter(Boolean)[0];
218
- if (firstSegment && discoveredLocales.has(firstSegment)) {
219
- localesInUrl.add(firstSegment);
220
- allFoundLocales.add(firstSegment);
221
- }
222
- } catch {}
223
- const missing = [...discoveredLocales].filter((locale) => !localesInUrl.has(locale));
224
- if (missing.length > 0 && missing.length < discoveredLocales.size) anyUrlMissingLocale = true;
225
- }
226
- const completelyMissing = [...discoveredLocales].filter((locale) => !allFoundLocales.has(locale));
227
- if (anyUrlMissingLocale || completelyMissing.length > 0) {
228
- noLocalizedUrlsForgotten = false;
229
- if (completelyMissing.length > 0) errors.push(`The following locales are completely missing from the sitemap: ${completelyMissing.join(", ")}`);
230
- }
392
+ let hasXDefault = false;
393
+ for (const { loc, alternates } of entries) {
394
+ const locLocale = getUrlLocale(loc, routing);
395
+ if (locLocale) foundLanguages.add(getLanguageCode(locLocale));
396
+ if (alternates.length === 0) continue;
397
+ hasAlternates = true;
398
+ const entryLanguages = /* @__PURE__ */ new Set();
399
+ for (const { hreflang, href } of alternates) {
400
+ if (!isAbsoluteUrl(href)) relativeAlternates.push(`${loc}: ${hreflang} → ${href}`);
401
+ if (hreflang.toLowerCase() === "x-default") {
402
+ hasXDefault = true;
403
+ continue;
231
404
  }
405
+ entryLanguages.add(getLanguageCode(hreflang));
406
+ foundLanguages.add(getLanguageCode(hreflang));
232
407
  }
233
- } catch (error) {
234
- errors.push(`Failed to fetch sitemap.xml: ${error instanceof Error ? error.message : "Unknown error"}`);
408
+ const normalizedLoc = normalizeUrl(loc);
409
+ if (!alternates.some(({ href }) => normalizeUrl(href) === normalizedLoc)) entriesWithoutSelfReference.push(loc);
410
+ if (entryLanguages.size < knownLocales.length) partiallyTranslatedCount++;
235
411
  }
236
- events.push({
237
- type: "sitemap_sitemapPresent",
238
- status: sitemapPresent ? "success" : "warning",
239
- details: {
240
- warning: sitemapPresent ? void 0 : "No sitemap.xml found",
241
- error: errors.length > 0 ? errors : void 0
242
- }
243
- });
244
- if (sitemapPresent) {
245
- events.push({
246
- type: "sitemap_noLocalizedUrlsForgotten",
247
- status: noLocalizedUrlsForgotten ? "success" : "warning",
248
- details: { warning: noLocalizedUrlsForgotten ? void 0 : errors }
249
- });
250
- events.push({
251
- type: "sitemap_hasXDefault",
252
- status: hasXDefault ? "success" : "warning",
253
- details: { warning: hasXDefault ? void 0 : "No x-default hreflang in sitemap" }
254
- });
255
- events.push({
256
- type: "sitemap_hasAlternates",
257
- status: hasAlternates ? "success" : "warning",
258
- details: { warning: hasAlternates ? void 0 : "No alternate language links found in sitemap" }
259
- });
412
+ const isSingleOriginRouting = routing.strategy !== "subdomain" && routing.strategy !== "domain";
413
+ if (knownLocales.length > 1) {
414
+ const missingLocales = isSingleOriginRouting ? knownLocales.filter((locale) => !foundLanguages.has(getLanguageCode(locale))) : [];
415
+ const issues = [...missingLocales.length > 0 ? [`Locales missing from the sitemap: ${missingLocales.join(", ")}`] : [], ...entriesWithoutSelfReference.slice(0, MAX_LISTED_LINKS).map((loc) => `${loc} is not listed among its own alternates`)];
416
+ emitCheck(emit, "sitemap_noLocalizedUrlsForgotten", issues.length === 0 ? "success" : "warning", issues.length === 0 ? {
417
+ message: `Every locale (${knownLocales.join(", ")}) is listed`,
418
+ partiallyTranslatedUrlCount: partiallyTranslatedCount
419
+ } : issues);
260
420
  }
421
+ if (!hasAlternates) emitCheck(emit, "sitemap_hasAlternates", "warning", "No alternate language links found in the sitemap");
422
+ else if (relativeAlternates.length > 0) emitCheck(emit, "sitemap_hasAlternates", "error", {
423
+ message: `${relativeAlternates.length} sitemap hreflang alternates are not absolute URLs (https://…): search engines ignore them`,
424
+ alternates: relativeAlternates.slice(0, MAX_LISTED_LINKS)
425
+ });
426
+ else emitCheck(emit, "sitemap_hasAlternates", "success", true);
427
+ emitCheck(emit, "sitemap_hasXDefault", hasXDefault ? "success" : "warning", hasXDefault ? true : "No x-default hreflang in the sitemap");
261
428
  };
262
429
 
263
430
  //#endregion
264
- export { checkBundleContent, checkCanonical, checkHtmlAttributes, checkLinguisticStructure, checkRobots, checkSitemap, checkUrlStructure, formatSize };
431
+ export { checkBundleContent, checkCanonical, checkHreflang, checkHreflangReciprocity, checkHtmlDir, checkHtmlLang, checkInternalLinks, checkLocaleConsistency, checkOgLocale, checkRobots, checkSitemap, checkXDefault, formatBundleAnalysis, formatSize };
265
432
  //# sourceMappingURL=checks.mjs.map