@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.
- package/dist/assets/installSkills/skills/bundle-optimization.md +123 -0
- package/dist/assets/installSkills/skills/compat.md +0 -4
- package/dist/assets/installSkills/skills/content.md +8 -2
- package/dist/assets/installSkills/skills/next-js.md +2 -2
- package/dist/assets/installSkills/skills/usage.md +1 -1
- package/dist/cjs/build.cjs +5 -0
- package/dist/cjs/cli.cjs +1 -0
- package/dist/cjs/docReview/alignBlocks.cjs +53 -36
- package/dist/cjs/docReview/alignBlocks.cjs.map +1 -1
- package/dist/cjs/docReview/rebuildDocument.cjs +3 -1
- package/dist/cjs/docReview/rebuildDocument.cjs.map +1 -1
- package/dist/cjs/docReview/segmentDocument.cjs +8 -5
- package/dist/cjs/docReview/segmentDocument.cjs.map +1 -1
- package/dist/cjs/init/frameworkSetup/nextAppRouter/transforms.cjs +4 -1
- package/dist/cjs/init/frameworkSetup/nextAppRouter/transforms.cjs.map +1 -1
- package/dist/cjs/init/index.cjs +2 -12
- package/dist/cjs/init/index.cjs.map +1 -1
- package/dist/cjs/init/utils/backendPackages.cjs +15 -0
- package/dist/cjs/init/utils/backendPackages.cjs.map +1 -0
- package/dist/cjs/init/utils/index.cjs +2 -3
- package/dist/cjs/installMCP/installMCP.cjs +1 -1
- package/dist/cjs/installMCP/installMCP.cjs.map +1 -1
- package/dist/cjs/installSkills/index.cjs +2 -0
- package/dist/cjs/installSkills/index.cjs.map +1 -1
- package/dist/cjs/loadDictionaries/loadDictionaries.cjs +9 -8
- package/dist/cjs/loadDictionaries/loadDictionaries.cjs.map +1 -1
- package/dist/cjs/loadDictionaries/loadLocalDictionaries.cjs +3 -1
- package/dist/cjs/loadDictionaries/loadLocalDictionaries.cjs.map +1 -1
- package/dist/cjs/loadDictionaries/loadRemoteDictionaries.cjs +4 -6
- package/dist/cjs/loadDictionaries/loadRemoteDictionaries.cjs.map +1 -1
- package/dist/cjs/loadDictionaries/log.cjs +1 -1
- package/dist/cjs/loadDictionaries/log.cjs.map +1 -1
- package/dist/cjs/loadDictionaries/logTypeScriptErrors.cjs +19 -12
- package/dist/cjs/loadDictionaries/logTypeScriptErrors.cjs.map +1 -1
- package/dist/cjs/loadDictionaries/remoteDictionaryClient.cjs +18 -0
- package/dist/cjs/loadDictionaries/remoteDictionaryClient.cjs.map +1 -0
- package/dist/cjs/prepareIntlayerServer.cjs +132 -0
- package/dist/cjs/prepareIntlayerServer.cjs.map +1 -0
- package/dist/cjs/scan/analyzeBundleContent.cjs +26 -12
- package/dist/cjs/scan/analyzeBundleContent.cjs.map +1 -1
- package/dist/cjs/scan/calculateScore.cjs +4 -1
- package/dist/cjs/scan/calculateScore.cjs.map +1 -1
- package/dist/cjs/scan/checks.cjs +394 -221
- package/dist/cjs/scan/checks.cjs.map +1 -1
- package/dist/cjs/scan/detection/checkDetails.cjs +56 -0
- package/dist/cjs/scan/detection/checkDetails.cjs.map +1 -0
- package/dist/cjs/scan/detection/classifyInternalLinks.cjs +47 -0
- package/dist/cjs/scan/detection/classifyInternalLinks.cjs.map +1 -0
- package/dist/cjs/scan/detection/detectRoutingStrategy.cjs +143 -0
- package/dist/cjs/scan/detection/detectRoutingStrategy.cjs.map +1 -0
- package/dist/cjs/scan/detection/detectTechnologies.cjs +89 -0
- package/dist/cjs/scan/detection/detectTechnologies.cjs.map +1 -0
- package/dist/cjs/scan/detection/index.cjs +36 -0
- package/dist/cjs/scan/detection/localeCode.cjs +118 -0
- package/dist/cjs/scan/detection/localeCode.cjs.map +1 -0
- package/dist/cjs/scan/detection/localizedPages.cjs +48 -0
- package/dist/cjs/scan/detection/localizedPages.cjs.map +1 -0
- package/dist/cjs/scan/detection/technologySignatures.cjs +564 -0
- package/dist/cjs/scan/detection/technologySignatures.cjs.map +1 -0
- package/dist/cjs/scan/detection/url.cjs +53 -0
- package/dist/cjs/scan/detection/url.cjs.map +1 -0
- package/dist/cjs/scan/fetchText.cjs +56 -0
- package/dist/cjs/scan/fetchText.cjs.map +1 -0
- package/dist/cjs/scan/index.cjs +59 -3
- package/dist/cjs/scan/parseHtml.cjs +55 -10
- package/dist/cjs/scan/parseHtml.cjs.map +1 -1
- package/dist/cjs/scan/robots.cjs +65 -0
- package/dist/cjs/scan/robots.cjs.map +1 -0
- package/dist/cjs/scan/runScanChecks.cjs +89 -0
- package/dist/cjs/scan/runScanChecks.cjs.map +1 -0
- package/dist/cjs/scan/scanWebsite.cjs +58 -52
- package/dist/cjs/scan/scanWebsite.cjs.map +1 -1
- package/dist/cjs/scan/sitemap.cjs +109 -0
- package/dist/cjs/scan/sitemap.cjs.map +1 -0
- package/dist/cjs/utils/chunkJSON.cjs +24 -9
- package/dist/cjs/utils/chunkJSON.cjs.map +1 -1
- package/dist/cjs/utils/getChunk.cjs +2 -2
- package/dist/cjs/utils/getChunk.cjs.map +1 -1
- package/dist/cjs/utils/getProcessChainCommands.cjs +117 -0
- package/dist/cjs/utils/getProcessChainCommands.cjs.map +1 -0
- package/dist/cjs/utils/index.cjs +2 -0
- package/dist/cjs/utils/runParallel/index.cjs +1 -1
- package/dist/cjs/utils/runParallel/index.cjs.map +1 -1
- package/dist/cjs/utils/runParallel/pidTree.cjs +23 -11
- package/dist/cjs/utils/runParallel/pidTree.cjs.map +1 -1
- package/dist/cjs/utils/runParallel/ps.cjs +8 -3
- package/dist/cjs/utils/runParallel/ps.cjs.map +1 -1
- package/dist/cjs/utils/runParallel/runTask.cjs +2 -1
- package/dist/cjs/utils/runParallel/runTask.cjs.map +1 -1
- package/dist/cjs/utils/runParallel/wmic.cjs +8 -3
- package/dist/cjs/utils/runParallel/wmic.cjs.map +1 -1
- package/dist/cjs/utils/startContentWatcher.cjs +99 -0
- package/dist/cjs/utils/startContentWatcher.cjs.map +1 -0
- package/dist/cjs/watcher.cjs +2 -2
- package/dist/cjs/watcher.cjs.map +1 -1
- package/dist/cjs/writeContentDeclaration/detectExportedComponentName.cjs +7 -4
- package/dist/cjs/writeContentDeclaration/detectExportedComponentName.cjs.map +1 -1
- package/dist/cjs/writeContentDeclaration/writeMarkdownFile.cjs +2 -2
- package/dist/cjs/writeContentDeclaration/writeMarkdownFile.cjs.map +1 -1
- package/dist/cjs/writeJsonIfChanged.cjs.map +1 -1
- package/dist/esm/build.mjs +2 -1
- package/dist/esm/cli.mjs +2 -2
- package/dist/esm/docReview/alignBlocks.mjs +53 -36
- package/dist/esm/docReview/alignBlocks.mjs.map +1 -1
- package/dist/esm/docReview/rebuildDocument.mjs +3 -1
- package/dist/esm/docReview/rebuildDocument.mjs.map +1 -1
- package/dist/esm/docReview/segmentDocument.mjs +8 -5
- package/dist/esm/docReview/segmentDocument.mjs.map +1 -1
- package/dist/esm/init/frameworkSetup/nextAppRouter/transforms.mjs +4 -1
- package/dist/esm/init/frameworkSetup/nextAppRouter/transforms.mjs.map +1 -1
- package/dist/esm/init/index.mjs +1 -11
- package/dist/esm/init/index.mjs.map +1 -1
- package/dist/esm/init/utils/backendPackages.mjs +14 -0
- package/dist/esm/init/utils/backendPackages.mjs.map +1 -0
- package/dist/esm/init/utils/index.mjs +2 -2
- package/dist/esm/installMCP/installMCP.mjs +1 -1
- package/dist/esm/installMCP/installMCP.mjs.map +1 -1
- package/dist/esm/installSkills/index.mjs +2 -0
- package/dist/esm/installSkills/index.mjs.map +1 -1
- package/dist/esm/loadDictionaries/loadDictionaries.mjs +9 -8
- package/dist/esm/loadDictionaries/loadDictionaries.mjs.map +1 -1
- package/dist/esm/loadDictionaries/loadLocalDictionaries.mjs +3 -1
- package/dist/esm/loadDictionaries/loadLocalDictionaries.mjs.map +1 -1
- package/dist/esm/loadDictionaries/loadRemoteDictionaries.mjs +3 -5
- package/dist/esm/loadDictionaries/loadRemoteDictionaries.mjs.map +1 -1
- package/dist/esm/loadDictionaries/log.mjs +1 -1
- package/dist/esm/loadDictionaries/log.mjs.map +1 -1
- package/dist/esm/loadDictionaries/logTypeScriptErrors.mjs +19 -12
- package/dist/esm/loadDictionaries/logTypeScriptErrors.mjs.map +1 -1
- package/dist/esm/loadDictionaries/remoteDictionaryClient.mjs +16 -0
- package/dist/esm/loadDictionaries/remoteDictionaryClient.mjs.map +1 -0
- package/dist/esm/prepareIntlayerServer.mjs +128 -0
- package/dist/esm/prepareIntlayerServer.mjs.map +1 -0
- package/dist/esm/scan/analyzeBundleContent.mjs +26 -12
- package/dist/esm/scan/analyzeBundleContent.mjs.map +1 -1
- package/dist/esm/scan/calculateScore.mjs +4 -1
- package/dist/esm/scan/calculateScore.mjs.map +1 -1
- package/dist/esm/scan/checks.mjs +387 -220
- package/dist/esm/scan/checks.mjs.map +1 -1
- package/dist/esm/scan/detection/checkDetails.mjs +54 -0
- package/dist/esm/scan/detection/checkDetails.mjs.map +1 -0
- package/dist/esm/scan/detection/classifyInternalLinks.mjs +46 -0
- package/dist/esm/scan/detection/classifyInternalLinks.mjs.map +1 -0
- package/dist/esm/scan/detection/detectRoutingStrategy.mjs +141 -0
- package/dist/esm/scan/detection/detectRoutingStrategy.mjs.map +1 -0
- package/dist/esm/scan/detection/detectTechnologies.mjs +87 -0
- package/dist/esm/scan/detection/detectTechnologies.mjs.map +1 -0
- package/dist/esm/scan/detection/index.mjs +10 -0
- package/dist/esm/scan/detection/localeCode.mjs +108 -0
- package/dist/esm/scan/detection/localeCode.mjs.map +1 -0
- package/dist/esm/scan/detection/localizedPages.mjs +46 -0
- package/dist/esm/scan/detection/localizedPages.mjs.map +1 -0
- package/dist/esm/scan/detection/technologySignatures.mjs +563 -0
- package/dist/esm/scan/detection/technologySignatures.mjs.map +1 -0
- package/dist/esm/scan/detection/url.mjs +47 -0
- package/dist/esm/scan/detection/url.mjs.map +1 -0
- package/dist/esm/scan/fetchText.mjs +55 -0
- package/dist/esm/scan/fetchText.mjs.map +1 -0
- package/dist/esm/scan/index.mjs +15 -3
- package/dist/esm/scan/parseHtml.mjs +52 -11
- package/dist/esm/scan/parseHtml.mjs.map +1 -1
- package/dist/esm/scan/robots.mjs +63 -0
- package/dist/esm/scan/robots.mjs.map +1 -0
- package/dist/esm/scan/runScanChecks.mjs +88 -0
- package/dist/esm/scan/runScanChecks.mjs.map +1 -0
- package/dist/esm/scan/scanWebsite.mjs +59 -53
- package/dist/esm/scan/scanWebsite.mjs.map +1 -1
- package/dist/esm/scan/sitemap.mjs +105 -0
- package/dist/esm/scan/sitemap.mjs.map +1 -0
- package/dist/esm/utils/chunkJSON.mjs +24 -9
- package/dist/esm/utils/chunkJSON.mjs.map +1 -1
- package/dist/esm/utils/getChunk.mjs +2 -2
- package/dist/esm/utils/getChunk.mjs.map +1 -1
- package/dist/esm/utils/getProcessChainCommands.mjs +115 -0
- package/dist/esm/utils/getProcessChainCommands.mjs.map +1 -0
- package/dist/esm/utils/index.mjs +2 -1
- package/dist/esm/utils/runParallel/index.mjs +1 -1
- package/dist/esm/utils/runParallel/index.mjs.map +1 -1
- package/dist/esm/utils/runParallel/pidTree.mjs +23 -11
- package/dist/esm/utils/runParallel/pidTree.mjs.map +1 -1
- package/dist/esm/utils/runParallel/ps.mjs +8 -3
- package/dist/esm/utils/runParallel/ps.mjs.map +1 -1
- package/dist/esm/utils/runParallel/runTask.mjs +2 -1
- package/dist/esm/utils/runParallel/runTask.mjs.map +1 -1
- package/dist/esm/utils/runParallel/wmic.mjs +8 -3
- package/dist/esm/utils/runParallel/wmic.mjs.map +1 -1
- package/dist/esm/utils/startContentWatcher.mjs +98 -0
- package/dist/esm/utils/startContentWatcher.mjs.map +1 -0
- package/dist/esm/watcher.mjs +1 -1
- package/dist/esm/watcher.mjs.map +1 -1
- package/dist/esm/writeContentDeclaration/detectExportedComponentName.mjs +7 -4
- package/dist/esm/writeContentDeclaration/detectExportedComponentName.mjs.map +1 -1
- package/dist/esm/writeContentDeclaration/writeMarkdownFile.mjs +2 -2
- package/dist/esm/writeContentDeclaration/writeMarkdownFile.mjs.map +1 -1
- package/dist/esm/writeJsonIfChanged.mjs +1 -1
- package/dist/esm/writeJsonIfChanged.mjs.map +1 -1
- package/dist/types/build.d.ts +2 -1
- package/dist/types/cli.d.ts +2 -2
- package/dist/types/docReview/rebuildDocument.d.ts.map +1 -1
- package/dist/types/docReview/segmentDocument.d.ts.map +1 -1
- package/dist/types/init/frameworkSetup/nextAppRouter/transforms.d.ts.map +1 -1
- package/dist/types/init/index.d.ts +1 -1
- package/dist/types/init/index.d.ts.map +1 -1
- package/dist/types/init/utils/backendPackages.d.ts +5 -0
- package/dist/types/init/utils/backendPackages.d.ts.map +1 -0
- package/dist/types/init/utils/index.d.ts +2 -2
- package/dist/types/installSkills/index.d.ts +1 -0
- package/dist/types/installSkills/index.d.ts.map +1 -1
- package/dist/types/loadDictionaries/loadRemoteDictionaries.d.ts.map +1 -1
- package/dist/types/loadDictionaries/logTypeScriptErrors.d.ts.map +1 -1
- package/dist/types/loadDictionaries/remoteDictionaryClient.d.ts +14 -0
- package/dist/types/loadDictionaries/remoteDictionaryClient.d.ts.map +1 -0
- package/dist/types/prepareIntlayerServer.d.ts +67 -0
- package/dist/types/prepareIntlayerServer.d.ts.map +1 -0
- package/dist/types/scan/analyzeBundleContent.d.ts.map +1 -1
- package/dist/types/scan/calculateScore.d.ts +4 -1
- package/dist/types/scan/calculateScore.d.ts.map +1 -1
- package/dist/types/scan/checks.d.ts +114 -21
- package/dist/types/scan/checks.d.ts.map +1 -1
- package/dist/types/scan/detection/checkDetails.d.ts +29 -0
- package/dist/types/scan/detection/checkDetails.d.ts.map +1 -0
- package/dist/types/scan/detection/classifyInternalLinks.d.ts +44 -0
- package/dist/types/scan/detection/classifyInternalLinks.d.ts.map +1 -0
- package/dist/types/scan/detection/detectRoutingStrategy.d.ts +60 -0
- package/dist/types/scan/detection/detectRoutingStrategy.d.ts.map +1 -0
- package/dist/types/scan/detection/detectTechnologies.d.ts +60 -0
- package/dist/types/scan/detection/detectTechnologies.d.ts.map +1 -0
- package/dist/types/scan/detection/index.d.ts +9 -0
- package/dist/types/scan/detection/localeCode.d.ts +49 -0
- package/dist/types/scan/detection/localeCode.d.ts.map +1 -0
- package/dist/types/scan/detection/localizedPages.d.ts +40 -0
- package/dist/types/scan/detection/localizedPages.d.ts.map +1 -0
- package/dist/types/scan/detection/technologySignatures.d.ts +48 -0
- package/dist/types/scan/detection/technologySignatures.d.ts.map +1 -0
- package/dist/types/scan/detection/url.d.ts +27 -0
- package/dist/types/scan/detection/url.d.ts.map +1 -0
- package/dist/types/scan/fetchText.d.ts +30 -0
- package/dist/types/scan/fetchText.d.ts.map +1 -0
- package/dist/types/scan/index.d.ts +16 -4
- package/dist/types/scan/parseHtml.d.ts +17 -0
- package/dist/types/scan/parseHtml.d.ts.map +1 -1
- package/dist/types/scan/robots.d.ts +23 -0
- package/dist/types/scan/robots.d.ts.map +1 -0
- package/dist/types/scan/runScanChecks.d.ts +43 -0
- package/dist/types/scan/runScanChecks.d.ts.map +1 -0
- package/dist/types/scan/scanWebsite.d.ts.map +1 -1
- package/dist/types/scan/sitemap.d.ts +49 -0
- package/dist/types/scan/sitemap.d.ts.map +1 -0
- package/dist/types/scan/types.d.ts +15 -0
- package/dist/types/scan/types.d.ts.map +1 -1
- package/dist/types/utils/chunkJSON.d.ts.map +1 -1
- package/dist/types/utils/getProcessChainCommands.d.ts +35 -0
- package/dist/types/utils/getProcessChainCommands.d.ts.map +1 -0
- package/dist/types/utils/index.d.ts +2 -1
- package/dist/types/utils/runParallel/pidTree.d.ts.map +1 -1
- package/dist/types/utils/startContentWatcher.d.ts +40 -0
- package/dist/types/utils/startContentWatcher.d.ts.map +1 -0
- package/dist/types/watcher.d.ts.map +1 -1
- package/dist/types/writeContentDeclaration/writeMarkdownFile.d.ts.map +1 -1
- package/package.json +15 -7
- package/dist/cjs/init/utils/devScript.cjs +0 -57
- package/dist/cjs/init/utils/devScript.cjs.map +0 -1
- package/dist/esm/init/utils/devScript.mjs +0 -55
- package/dist/esm/init/utils/devScript.mjs.map +0 -1
- package/dist/types/init/utils/devScript.d.ts +0 -48
- package/dist/types/init/utils/devScript.d.ts.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"checks.mjs","names":[],"sources":["../../../src/scan/checks.ts"],"sourcesContent":["import { ALL_LOCALES } from '@intlayer/types/allLocales';\nimport { analyzeBundleContent } from './analyzeBundleContent';\nimport {\n type Anchor,\n extractHreflangs,\n extractHtmlDir,\n extractHtmlLang,\n hasCanonical,\n} from './parseHtml';\nimport type {\n BundleChunkInput,\n BundleContentAnalysis,\n ScanEvent,\n} from './types';\n\n/** Format a byte count as a human-readable size. */\nexport const formatSize = (bytes: number): string => {\n if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB`;\n return `${bytes} B`;\n};\n\n/**\n * Check the `<html>` element attributes (`lang`, `dir`) and the resulting\n * current-locale signal. Returns the detected language tag.\n */\nexport const checkHtmlAttributes = (\n html: string,\n targetUrl: string,\n events: ScanEvent[]\n): { langTag: string | undefined } => {\n const langTag = extractHtmlLang(html);\n const dirTag = extractHtmlDir(html);\n\n events.push({\n type: `url_htmlLang\\\\${targetUrl}`,\n status: langTag ? 'success' : 'error',\n details: {\n success: langTag,\n error: langTag ? undefined : 'Missing html lang attribute',\n },\n });\n\n events.push({\n type: `url_currentLocale\\\\${targetUrl}`,\n status: langTag ? 'success' : 'warning',\n details: {\n success: langTag,\n warning: langTag ? undefined : 'No locale detected',\n },\n });\n\n events.push({\n type: `url_htmlDir\\\\${targetUrl}`,\n status: dirTag ? 'success' : 'warning',\n details: {\n success: dirTag,\n warning: dirTag ? undefined : 'Missing html dir attribute',\n },\n });\n\n return { langTag };\n};\n\n/** Check the presence of a canonical link. */\nexport const checkCanonical = (\n html: string,\n targetUrl: string,\n events: ScanEvent[]\n): void => {\n const present = hasCanonical(html);\n events.push({\n type: `url_hasCanonical\\\\${targetUrl}`,\n status: present ? 'success' : 'warning',\n details: { warning: present ? undefined : 'Missing canonical link' },\n });\n};\n\n/**\n * Check the page's hreflang structure and collect discovered locales into\n * `localesSet`.\n */\nexport const checkLinguisticStructure = (\n html: string,\n targetUrl: string,\n localesSet: Set<string>,\n events: ScanEvent[]\n): void => {\n const langTag = extractHtmlLang(html);\n if (langTag) localesSet.add(langTag);\n\n const hreflangs = extractHreflangs(html);\n for (const { hreflang } of hreflangs) {\n if (hreflang !== 'x-default') localesSet.add(hreflang);\n }\n\n const hasXDefault = hreflangs.some((h) => h.hreflang === 'x-default');\n\n events.push({\n type: `url_hreflang\\\\${targetUrl}`,\n status: hreflangs.length > 0 ? 'success' : 'warning',\n details: {\n success: hreflangs.length > 0 ? hreflangs : undefined,\n warning: hreflangs.length === 0 ? 'No hreflang tags found' : undefined,\n },\n });\n\n events.push({\n type: `url_hasXDefault\\\\${targetUrl}`,\n status: hasXDefault ? 'success' : 'error',\n details: {\n error: hasXDefault ? undefined : 'Missing x-default hreflang link',\n },\n });\n};\n\nconst normalizeHost = (host: string): string =>\n host.startsWith('www.') ? host.slice(4) : host;\n\nconst localeValues = Object.values(ALL_LOCALES) as string[];\n\n/**\n * Check whether internal links carry a locale segment, mirroring the hosted\n * audit's URL-structure analysis but operating on parsed anchors instead of a\n * live DOM.\n */\nexport const checkUrlStructure = (\n anchors: Anchor[],\n origin: string,\n targetUrl: string,\n events: ScanEvent[]\n): void => {\n const targetHostname = normalizeHost(new URL(origin).hostname);\n\n let localizedCount = 0;\n let totalInternalCount = 0;\n const nonLocalizedLinks: string[] = [];\n\n for (const { href } of anchors) {\n if (!href || href.startsWith('#') || href.startsWith('javascript:'))\n continue;\n\n try {\n const url = new URL(href, origin);\n if (normalizeHost(url.hostname) !== targetHostname) continue;\n\n totalInternalCount++;\n const path = url.pathname.toLowerCase();\n const hostname = url.hostname.toLowerCase();\n\n const hasLocaleInPath = localeValues.some((locale) => {\n const l = locale.toLowerCase();\n return path === `/${l}` || path.includes(`/${l}/`);\n });\n const hasLocaleInSubdomain = localeValues.some((locale) => {\n const l = locale.toLowerCase();\n return hostname.startsWith(`${l}.`) || hostname.includes(`.${l}.`);\n });\n\n if (hasLocaleInPath || hasLocaleInSubdomain) {\n localizedCount++;\n } else {\n nonLocalizedLinks.push(href);\n }\n } catch {\n /* ignore malformed URLs */\n }\n }\n\n const hasLocalizedLinks = localizedCount > 0;\n const allAnchorsLocalized =\n totalInternalCount === 0 || localizedCount === totalInternalCount;\n\n events.push({\n type: `url_hasLocalizedLinks\\\\${targetUrl}`,\n status: hasLocalizedLinks ? 'success' : 'warning',\n details: {\n success: hasLocalizedLinks\n ? `${localizedCount} localized links found out of ${totalInternalCount} internal links`\n : undefined,\n warning: hasLocalizedLinks ? undefined : 'No localized links found',\n },\n });\n\n events.push({\n type: `url_allAnchorsLocalized\\\\${targetUrl}`,\n status: allAnchorsLocalized ? 'success' : 'warning',\n details: {\n warning: allAnchorsLocalized\n ? undefined\n : {\n message: 'Some internal links are not localized',\n links: nonLocalizedLinks,\n },\n },\n });\n};\n\n/**\n * Analyze the JavaScript bundles for unused locale content and emit the\n * corresponding event. Returns the analysis so callers can report on it.\n */\nexport const checkBundleContent = (\n chunks: BundleChunkInput[],\n html: string,\n currentLocale: string | undefined,\n targetUrl: string,\n totalPageSize: number,\n events: ScanEvent[]\n): BundleContentAnalysis | undefined => {\n if (!currentLocale) {\n events.push({\n type: `url_unusedBundleContent\\\\${targetUrl}`,\n status: 'warning',\n details: {\n warning: 'Cannot analyse bundle content: page locale not detected',\n },\n });\n return undefined;\n }\n\n const analysis = analyzeBundleContent(\n chunks,\n html,\n currentLocale,\n totalPageSize\n );\n\n // Status is driven by the main bundle — lazy chunks with unused content are expected.\n const mainBundleMaxUnused = analysis.mainBundleChunks.reduce(\n (max, c) => Math.max(max, c.unusedPercent),\n 0\n );\n\n const status =\n mainBundleMaxUnused === 0\n ? 'success'\n : mainBundleMaxUnused <= 30\n ? 'warning'\n : 'error';\n\n events.push({\n type: `url_unusedBundleContent\\\\${targetUrl}`,\n status,\n details: { [status]: analysis },\n });\n\n return analysis;\n};\n\n/** Fetch and check `robots.txt`, emitting robots-related events. */\nexport const checkRobots = async (\n origin: string,\n discoveredLocales: Set<string>,\n userAgent: string,\n events: ScanEvent[]\n): Promise<void> => {\n let robotsPresent = false;\n let noLocalizedUrlsForgotten = true;\n const errors: string[] = [];\n\n try {\n const response = await fetch(`${origin}/robots.txt`, {\n headers: { 'User-Agent': userAgent },\n });\n\n if (response.ok) {\n robotsPresent = true;\n const content = await response.text();\n\n if (content && discoveredLocales.size > 0) {\n const disallowedPaths = content\n .split('\\n')\n .map((line) => line.trim().toLowerCase())\n .filter((line) => line.startsWith('disallow:'))\n .map((line) => line.slice('disallow:'.length).trim());\n\n for (const locale of discoveredLocales) {\n for (const path of disallowedPaths) {\n if (path === `/${locale}` || path === `/${locale}/`) {\n noLocalizedUrlsForgotten = false;\n errors.push(\n `Locale path \"${locale}\" appears to be blocked in robots.txt: ${path}`\n );\n }\n }\n }\n }\n }\n } catch (error) {\n errors.push(\n `Failed to fetch robots.txt: ${error instanceof Error ? error.message : 'Unknown error'}`\n );\n }\n\n events.push({\n type: 'robots_robotsPresent',\n status: robotsPresent ? 'success' : 'warning',\n details: {\n warning: robotsPresent ? undefined : 'No robots.txt found',\n error: errors.length > 0 ? errors : undefined,\n },\n });\n\n if (robotsPresent) {\n events.push({\n type: 'robots_noLocalizedUrlsForgotten',\n status: noLocalizedUrlsForgotten ? 'success' : 'error',\n details: { error: noLocalizedUrlsForgotten ? undefined : errors },\n });\n }\n};\n\n/** Fetch and check `sitemap.xml`, emitting sitemap-related events. */\nexport const checkSitemap = async (\n origin: string,\n discoveredLocales: Set<string>,\n userAgent: string,\n events: ScanEvent[]\n): Promise<void> => {\n let sitemapPresent = false;\n let hasXDefault = false;\n let hasAlternates = false;\n let noLocalizedUrlsForgotten = true;\n const errors: string[] = [];\n\n try {\n const response = await fetch(`${origin}/sitemap.xml`, {\n headers: { 'User-Agent': userAgent },\n });\n\n if (response.ok) {\n sitemapPresent = true;\n const content = await response.text();\n\n const hreflangMatches = content.match(/hreflang\\s*=\\s*\"([^\"]+)\"/gi) ?? [];\n const hreflangs = hreflangMatches.map((m) =>\n m.replace(/hreflang\\s*=\\s*\"/i, '').replace(/\"$/, '')\n );\n hasAlternates = hreflangs.length > 0;\n hasXDefault = hreflangs.includes('x-default');\n\n if (discoveredLocales.size > 0) {\n const urlBlocks = content.match(/<url\\b[\\s\\S]*?<\\/url>/gi) ?? [];\n const allFoundLocales = new Set<string>();\n let anyUrlMissingLocale = false;\n\n for (const block of urlBlocks) {\n const localesInUrl = new Set<string>();\n\n for (const hreflang of block.match(/hreflang\\s*=\\s*\"([^\"]+)\"/gi) ??\n []) {\n const value = hreflang\n .replace(/hreflang\\s*=\\s*\"/i, '')\n .replace(/\"$/, '');\n if (value !== 'x-default') {\n localesInUrl.add(value);\n allFoundLocales.add(value);\n }\n }\n\n const loc = block.match(/<loc>([\\s\\S]*?)<\\/loc>/i)?.[1]?.trim();\n if (loc) {\n try {\n const firstSegment = new URL(loc).pathname\n .split('/')\n .filter(Boolean)[0];\n if (firstSegment && discoveredLocales.has(firstSegment)) {\n localesInUrl.add(firstSegment);\n allFoundLocales.add(firstSegment);\n }\n } catch {\n /* invalid loc URL, skip */\n }\n }\n\n const missing = [...discoveredLocales].filter(\n (locale) => !localesInUrl.has(locale)\n );\n if (missing.length > 0 && missing.length < discoveredLocales.size) {\n anyUrlMissingLocale = true;\n }\n }\n\n const completelyMissing = [...discoveredLocales].filter(\n (locale) => !allFoundLocales.has(locale)\n );\n if (anyUrlMissingLocale || completelyMissing.length > 0) {\n noLocalizedUrlsForgotten = false;\n if (completelyMissing.length > 0) {\n errors.push(\n `The following locales are completely missing from the sitemap: ${completelyMissing.join(', ')}`\n );\n }\n }\n }\n }\n } catch (error) {\n errors.push(\n `Failed to fetch sitemap.xml: ${error instanceof Error ? error.message : 'Unknown error'}`\n );\n }\n\n events.push({\n type: 'sitemap_sitemapPresent',\n status: sitemapPresent ? 'success' : 'warning',\n details: {\n warning: sitemapPresent ? undefined : 'No sitemap.xml found',\n error: errors.length > 0 ? errors : undefined,\n },\n });\n\n if (sitemapPresent) {\n events.push({\n type: 'sitemap_noLocalizedUrlsForgotten',\n status: noLocalizedUrlsForgotten ? 'success' : 'warning',\n details: { warning: noLocalizedUrlsForgotten ? undefined : errors },\n });\n\n events.push({\n type: 'sitemap_hasXDefault',\n status: hasXDefault ? 'success' : 'warning',\n details: {\n warning: hasXDefault ? undefined : 'No x-default hreflang in sitemap',\n },\n });\n\n events.push({\n type: 'sitemap_hasAlternates',\n status: hasAlternates ? 'success' : 'warning',\n details: {\n warning: hasAlternates\n ? undefined\n : 'No alternate language links found in sitemap',\n },\n });\n }\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"checks.mjs","names":[],"sources":["../../../src/scan/checks.ts"],"sourcesContent":["import { analyzeBundleContent } from './analyzeBundleContent';\nimport { classifyInternalLinks } from './detection/classifyInternalLinks';\nimport {\n getUrlLocale,\n type RoutingDetection,\n} from './detection/detectRoutingStrategy';\nimport {\n getLanguageCode,\n isRightToLeftLocale,\n isSameLanguage,\n isValidLocaleCode,\n isValidOpenGraphLocale,\n normalizeLocaleCode,\n toOpenGraphLocale,\n} from './detection/localeCode';\nimport { isAbsoluteUrl, normalizeUrl, parseUrl } from './detection/url';\nimport { fetchText, type ScanFetchOptions } from './fetchText';\nimport {\n type Anchor,\n extractHreflangs,\n extractHtmlLang,\n type HreflangLink,\n} from './parseHtml';\nimport { isPathAllowedByRobots, parseRobots } from './robots';\nimport { collectSitemapEntries, getDefaultSitemapUrls } from './sitemap';\nimport type {\n BundleChunkInput,\n BundleContentAnalysis,\n ChunkAnalysis,\n ScanCheckStatus,\n ScanEvent,\n} from './types';\n\n/** Receives every check result as soon as it is produced. */\nexport type EmitScanEvent = (event: ScanEvent) => void;\n\n/** Signals of the scanned page shared by the page-level checks. */\nexport type PageSignals = {\n targetUrl: string;\n langTag?: string;\n dirTag?: string;\n ogLocale?: string;\n /** `og:locale:alternate` values. */\n ogLocaleAlternates?: string[];\n /** Raw `href` of the canonical link (`''` when present but empty). */\n canonicalHref?: string;\n hreflangs: HreflangLink[];\n anchors: Anchor[];\n routing: RoutingDetection;\n /** Locale of the page: from its URL when possible, else `<html lang>`. */\n pageLocale?: string;\n};\n\n/** Maximum number of links listed in a check's details. */\nconst MAX_LISTED_LINKS = 30;\n/** Maximum number of hreflang alternates fetched for the reciprocity check. */\nconst MAX_FETCHED_ALTERNATES = 8;\n/** Statuses meaning the site refuses automated requests, not a broken page. */\nconst BOT_BLOCKING_STATUSES = new Set([401, 403, 429]);\n\n/** Format a byte count as a human-readable size. */\nexport const formatSize = (bytes: number): string => {\n if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB`;\n return `${bytes} B`;\n};\n\n/** Build a URL-scoped check type (`url_htmlLang\\https://…`). */\nconst urlCheckType = (checkName: string, targetUrl: string): string =>\n `${checkName}\\\\${targetUrl}`;\n\n/** Emit an event whose details are attached to its own status. */\nconst emitCheck = (\n emit: EmitScanEvent,\n type: string,\n status: ScanCheckStatus,\n details?: unknown\n): void =>\n emit({\n type,\n status,\n details: details === undefined ? undefined : { [status]: details },\n });\n\n/** Render an anchor as an HTML line for the details popover. */\nconst formatAnchor = ({ href, text }: Anchor): string =>\n `<a href=\"${href}\">${text.slice(0, 60) || 'Link'}</a>`;\n\n/* ------------------------------------------------------------------------ */\n/* <html> attributes */\n/* ------------------------------------------------------------------------ */\n\n/** `url_htmlLang`: `<html lang>` is present and a valid BCP 47 tag. */\nexport const checkHtmlLang = (\n { targetUrl, langTag }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_htmlLang', targetUrl);\n if (!langTag) {\n emitCheck(emit, type, 'error', 'Missing html lang attribute');\n } else if (!isValidLocaleCode(langTag)) {\n emitCheck(\n emit,\n type,\n 'error',\n `\"${langTag}\" is not a valid BCP 47 language tag (expected e.g. \"en\" or \"en-US\")`\n );\n } else {\n emitCheck(emit, type, 'success', langTag);\n }\n};\n\n/**\n * `url_htmlDir`: the text direction matches the language. `dir` is only\n * required for right-to-left languages, `ltr` being the browser default.\n */\nexport const checkHtmlDir = (\n { targetUrl, langTag, dirTag }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_htmlDir', targetUrl);\n const direction = dirTag?.toLowerCase();\n\n if (!langTag) {\n emitCheck(\n emit,\n type,\n 'warning',\n 'Cannot verify the text direction: html lang is missing'\n );\n return;\n }\n\n const expectedDirection = isRightToLeftLocale(langTag) ? 'rtl' : 'ltr';\n\n if (\n direction === expectedDirection ||\n (!direction && expectedDirection === 'ltr')\n ) {\n emitCheck(emit, type, 'success', direction ?? 'ltr (browser default)');\n return;\n }\n\n emitCheck(\n emit,\n type,\n 'error',\n direction\n ? `dir=\"${direction}\" does not match the ${expectedDirection.toUpperCase()} language \"${langTag}\"`\n : `Missing dir=\"rtl\" for the right-to-left language \"${langTag}\"`\n );\n};\n\n/**\n * `url_currentLocale`: the locale announced by `<html lang>`, the URL and the\n * self hreflang entry agree.\n */\nexport const checkLocaleConsistency = (\n { targetUrl, langTag, hreflangs, routing }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_currentLocale', targetUrl);\n const normalizedTargetUrl = normalizeUrl(targetUrl);\n const selfHreflang = hreflangs.find(\n ({ hreflang, href }) =>\n hreflang.toLowerCase() !== 'x-default' &&\n normalizeUrl(href, targetUrl) === normalizedTargetUrl\n )?.hreflang;\n\n const signals = {\n htmlLang: langTag,\n urlLocale: routing.urlLocale,\n selfHreflang,\n };\n const definedLocales = Object.values(signals).filter(\n (locale): locale is string => Boolean(locale)\n );\n\n if (definedLocales.length === 0) {\n emitCheck(emit, type, 'warning', 'No locale detected on the page');\n return;\n }\n\n const firstLocale = definedLocales[0];\n const isConsistent = definedLocales.every((locale) =>\n isSameLanguage(locale, firstLocale)\n );\n\n emitCheck(\n emit,\n type,\n isConsistent ? 'success' : 'error',\n isConsistent\n ? signals\n : {\n message:\n 'The page announces different locales in html lang, its URL and its hreflang entry',\n ...signals,\n }\n );\n};\n\n/** Describe an invalid Open Graph locale, with the value to use instead. */\nconst formatInvalidOpenGraphLocale = (\n localeCode: string,\n langTag: string | undefined,\n property: string\n): string => {\n const suggestion = toOpenGraphLocale(localeCode, langTag);\n return `${property} \"${localeCode}\" is not a valid Open Graph locale (expected language_TERRITORY${\n suggestion ? `, e.g. \"${suggestion}\"` : ''\n }): crawlers ignore it and fall back to en_US`;\n};\n\n/**\n * `url_ogLocale`: `og:locale` is set, uses the Open Graph `language_TERRITORY`\n * format (`en_GB`, not `en` nor `en-GB`) and matches `<html lang>`.\n * `og:locale:alternate` values must use the same format.\n */\nexport const checkOgLocale = (\n { targetUrl, ogLocale, ogLocaleAlternates = [], langTag }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_ogLocale', targetUrl);\n\n if (!ogLocale) {\n emitCheck(\n emit,\n type,\n 'warning',\n 'Missing <meta property=\"og:locale\">: social previews default to en_US'\n );\n return;\n }\n\n if (!isValidOpenGraphLocale(ogLocale)) {\n emitCheck(\n emit,\n type,\n 'error',\n formatInvalidOpenGraphLocale(ogLocale, langTag, 'og:locale')\n );\n return;\n }\n\n if (langTag && !isSameLanguage(ogLocale, langTag)) {\n emitCheck(\n emit,\n type,\n 'warning',\n `og:locale \"${ogLocale}\" does not match html lang \"${langTag}\"`\n );\n return;\n }\n\n const invalidAlternates = ogLocaleAlternates.filter(\n (alternate) => !isValidOpenGraphLocale(alternate)\n );\n\n if (invalidAlternates.length > 0) {\n emitCheck(\n emit,\n type,\n 'warning',\n invalidAlternates\n .map((alternate) =>\n formatInvalidOpenGraphLocale(\n alternate,\n undefined,\n 'og:locale:alternate'\n )\n )\n .join('\\n')\n );\n return;\n }\n\n emitCheck(emit, type, 'success', ogLocale);\n};\n\n/* ------------------------------------------------------------------------ */\n/* Canonical & hreflang tags */\n/* ------------------------------------------------------------------------ */\n\n/**\n * `url_hasCanonical`: a canonical link exists and does not point to another\n * locale version (which would de-index the current one).\n */\nexport const checkCanonical = (\n { targetUrl, canonicalHref, hreflangs, routing, pageLocale }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_hasCanonical', targetUrl);\n\n if (canonicalHref === undefined || canonicalHref === '') {\n emitCheck(emit, type, 'warning', 'Missing canonical link');\n return;\n }\n\n const canonicalUrl = normalizeUrl(canonicalHref, targetUrl);\n const otherLocaleAlternate = hreflangs.find(\n ({ hreflang, href }) =>\n hreflang.toLowerCase() !== 'x-default' &&\n normalizeUrl(href, targetUrl) === canonicalUrl &&\n !isSameLanguage(hreflang, pageLocale)\n );\n const canonicalLocale = getUrlLocale(canonicalUrl, routing);\n\n if (\n otherLocaleAlternate ||\n (canonicalLocale &&\n pageLocale &&\n !isSameLanguage(canonicalLocale, pageLocale))\n ) {\n emitCheck(emit, type, 'error', {\n 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`,\n canonical: canonicalUrl,\n });\n return;\n }\n\n emitCheck(\n emit,\n type,\n 'success',\n canonicalUrl === normalizeUrl(targetUrl)\n ? canonicalUrl\n : {\n message: 'Canonical points to another URL of the same locale',\n canonical: canonicalUrl,\n }\n );\n};\n\n/**\n * `url_hreflang`: hreflang tags exist, use valid codes and absolute URLs, have\n * no duplicates, and include a self reference.\n */\nexport const checkHreflang = (\n { targetUrl, hreflangs, canonicalHref }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_hreflang', targetUrl);\n\n if (hreflangs.length === 0) {\n emitCheck(emit, type, 'warning', 'No hreflang tags found');\n return;\n }\n\n const issues: string[] = [];\n\n const invalidCodes = hreflangs\n .map(({ hreflang }) => hreflang)\n .filter((hreflang) => !isValidLocaleCode(hreflang));\n if (invalidCodes.length > 0) {\n issues.push(`Invalid hreflang codes: ${invalidCodes.join(', ')}`);\n }\n\n const ukCodes = hreflangs\n .map(({ hreflang }) => hreflang)\n .filter((hreflang) => /-uk$/i.test(hreflang));\n if (ukCodes.length > 0) {\n issues.push(\n `\"${ukCodes.join(', ')}\": the United Kingdom region code is \"GB\" (e.g. en-GB)`\n );\n }\n\n // Search engines ignore alternates that are not fully qualified.\n const relativeHrefs = hreflangs.filter(({ href }) => !isAbsoluteUrl(href));\n if (relativeHrefs.length > 0) {\n issues.push(\n `hreflang URLs must be absolute (https://…), search engines ignore: ${relativeHrefs\n .map(({ hreflang, href }) => `${hreflang} → ${href}`)\n .join(', ')}`\n );\n }\n\n const urlsByCode = new Map<string, Set<string>>();\n for (const { hreflang, href } of hreflangs) {\n const code = normalizeLocaleCode(hreflang);\n const urls = urlsByCode.get(code) ?? new Set<string>();\n urls.add(normalizeUrl(href, targetUrl));\n urlsByCode.set(code, urls);\n }\n const conflictingCodes = [...urlsByCode.entries()]\n .filter(([, urls]) => urls.size > 1)\n .map(([code]) => code);\n if (conflictingCodes.length > 0) {\n issues.push(\n `Same hreflang declared with different URLs: ${conflictingCodes.join(', ')}`\n );\n }\n\n const selfUrls = new Set([normalizeUrl(targetUrl)]);\n if (canonicalHref) selfUrls.add(normalizeUrl(canonicalHref, targetUrl));\n const hasSelfReference = hreflangs.some(\n ({ hreflang, href }) =>\n hreflang.toLowerCase() !== 'x-default' &&\n selfUrls.has(normalizeUrl(href, targetUrl))\n );\n if (!hasSelfReference) {\n issues.push('No hreflang entry points to the page itself (self reference)');\n }\n\n const status: ScanCheckStatus =\n relativeHrefs.length > 0\n ? 'error'\n : issues.length > 0\n ? 'warning'\n : 'success';\n\n emitCheck(\n emit,\n type,\n status,\n issues.length > 0 ? { issues, hreflangs } : hreflangs\n );\n};\n\n/** `url_hasXDefault`: an `x-default` hreflang is declared. */\nexport const checkXDefault = (\n { targetUrl, hreflangs }: PageSignals,\n emit: EmitScanEvent\n): void => {\n const type = urlCheckType('url_hasXDefault', targetUrl);\n const xDefault = hreflangs.find(\n ({ hreflang }) => hreflang.toLowerCase() === 'x-default'\n );\n\n if (xDefault) {\n emitCheck(emit, type, 'success', xDefault.href);\n } else if (hreflangs.length === 0) {\n emitCheck(\n emit,\n type,\n 'warning',\n 'No hreflang tags, so no x-default either'\n );\n } else {\n emitCheck(emit, type, 'error', 'Missing x-default hreflang link');\n }\n};\n\n/** Outcome of fetching one hreflang alternate. */\ntype AlternateVerification = {\n url: string;\n hreflang: string;\n /** `broken`: 4xx/5xx; `unverifiable`: unreachable or blocking bots. */\n outcome: 'valid' | 'invalid' | 'broken' | 'unverifiable';\n issues: string[];\n};\n\n/** Fetch an alternate and check it forms a valid hreflang pair with the page. */\nconst verifyAlternate = async (\n alternateUrl: string,\n hreflang: string,\n selfUrls: Set<string>,\n fetchOptions: ScanFetchOptions\n): Promise<AlternateVerification> => {\n const response = await fetchText(alternateUrl, fetchOptions);\n const verification = { url: alternateUrl, hreflang };\n\n if (!response || BOT_BLOCKING_STATUSES.has(response.status)) {\n return { ...verification, outcome: 'unverifiable', issues: [] };\n }\n if (!response.ok) {\n return {\n ...verification,\n outcome: 'broken',\n issues: [`HTTP ${response.status}`],\n };\n }\n\n const issues: string[] = [];\n if (response.redirected) issues.push(`redirects to ${response.finalUrl}`);\n\n const hasReturnLink = extractHreflangs(response.text).some(({ href }) =>\n selfUrls.has(normalizeUrl(href, response.finalUrl))\n );\n if (!hasReturnLink) issues.push('no hreflang link back to this page');\n\n const alternateLang = extractHtmlLang(response.text);\n if (alternateLang && !isSameLanguage(alternateLang, hreflang)) {\n issues.push(`html lang is \"${alternateLang}\"`);\n }\n\n return {\n ...verification,\n outcome: issues.length > 0 ? 'invalid' : 'valid',\n issues,\n };\n};\n\n/**\n * `url_hreflangReciprocal`: every alternate answers with a 200, is not\n * redirected, links back to this page and declares the expected language.\n * Search engines ignore hreflang pairs without a return link.\n */\nexport const checkHreflangReciprocity = async (\n { targetUrl, hreflangs, canonicalHref }: PageSignals,\n fetchOptions: ScanFetchOptions,\n emit: EmitScanEvent\n): Promise<void> => {\n const type = urlCheckType('url_hreflangReciprocal', targetUrl);\n const selfUrls = new Set([normalizeUrl(targetUrl)]);\n if (canonicalHref) selfUrls.add(normalizeUrl(canonicalHref, targetUrl));\n\n const alternatesByUrl = new Map<string, string>();\n for (const { hreflang, href } of hreflangs) {\n if (hreflang.toLowerCase() === 'x-default') continue;\n const absoluteUrl = parseUrl(href, targetUrl)?.href;\n if (!absoluteUrl || selfUrls.has(normalizeUrl(absoluteUrl))) continue;\n if (!alternatesByUrl.has(absoluteUrl)) {\n alternatesByUrl.set(absoluteUrl, hreflang);\n }\n }\n\n if (alternatesByUrl.size === 0) return;\n\n const verifications = await Promise.all(\n [...alternatesByUrl.entries()]\n .slice(0, MAX_FETCHED_ALTERNATES)\n .map(([alternateUrl, hreflang]) =>\n verifyAlternate(alternateUrl, hreflang, selfUrls, fetchOptions)\n )\n );\n\n const unverifiableUrls = verifications\n .filter(({ outcome }) => outcome === 'unverifiable')\n .map(({ url }) => url);\n const failingVerifications = verifications.filter(\n ({ outcome }) => outcome === 'broken' || outcome === 'invalid'\n );\n const verifiedCount = verifications.length - unverifiableUrls.length;\n\n if (verifiedCount === 0) {\n emitCheck(emit, type, 'warning', {\n message:\n 'The alternates could not be verified: they are unreachable or block automated requests',\n urls: unverifiableUrls,\n });\n return;\n }\n\n if (failingVerifications.length === 0) {\n emitCheck(\n emit,\n type,\n 'success',\n `${verifiedCount} alternates checked, all reciprocal`\n );\n return;\n }\n\n emitCheck(\n emit,\n type,\n failingVerifications.some(({ outcome }) => outcome === 'broken')\n ? 'error'\n : 'warning',\n {\n message: `${failingVerifications.length} of ${verifiedCount} checked hreflang alternates are not valid return pairs`,\n alternates: failingVerifications.map(\n ({ hreflang, url, issues }) =>\n `${hreflang} ${url}: ${issues.join(', ')}`\n ),\n unverifiableUrls,\n }\n );\n};\n\n/* ------------------------------------------------------------------------ */\n/* Internal links */\n/* ------------------------------------------------------------------------ */\n\n/**\n * Link checks, interpreted through the detected routing strategy (so an\n * unprefixed link is correct on the default locale of a `prefix-no-default`\n * site, and `?lang=` links are understood on `search-params` sites):\n *\n * - `url_hasLocalizedLinks`: internal links keep the page locale\n * - `url_allAnchorsLocalized`: no internal link loses or switches the locale\n * - `url_hasLangSelector`: crawlable links to the other locale versions exist\n */\nexport const checkInternalLinks = (\n signals: PageSignals,\n emit: EmitScanEvent\n): void => {\n const { targetUrl, routing, pageLocale } = signals;\n const {\n internalLinks,\n sameLocaleLinks,\n otherLocaleLinks,\n unlocalizedLinks,\n switcherLinks,\n } = classifyInternalLinks(signals);\n const listLinks = (links: Anchor[]): string[] =>\n links.slice(0, MAX_LISTED_LINKS).map(formatAnchor);\n\n const localizedLinksType = urlCheckType('url_hasLocalizedLinks', targetUrl);\n const isLocaleInUrl =\n routing.strategy !== 'no-prefix' && routing.strategy !== 'unknown';\n\n if (internalLinks.length === 0) {\n emitCheck(emit, localizedLinksType, 'warning', {\n message: 'No internal links found on the page',\n links: listLinks(switcherLinks),\n });\n } else if (!isLocaleInUrl || !pageLocale) {\n emitCheck(emit, localizedLinksType, 'warning', {\n message: `The locale is not reflected in the URLs (routing: ${routing.strategy}). Each locale needs its own crawlable URL to be indexed.`,\n links: listLinks(internalLinks),\n });\n } else if (sameLocaleLinks.length === 0) {\n emitCheck(emit, localizedLinksType, 'warning', {\n message: `None of the ${internalLinks.length} internal links points to the \"${pageLocale}\" version (routing: ${routing.strategy})`,\n links: listLinks(internalLinks),\n });\n } else {\n emitCheck(emit, localizedLinksType, 'success', {\n message: `${sameLocaleLinks.length} of ${internalLinks.length} internal links point to the \"${pageLocale}\" version (routing: ${routing.strategy})`,\n links: listLinks(sameLocaleLinks),\n });\n }\n\n if (isLocaleInUrl && pageLocale && internalLinks.length > 0) {\n const brokenLinks = [...otherLocaleLinks, ...unlocalizedLinks];\n emitCheck(\n emit,\n urlCheckType('url_allAnchorsLocalized', targetUrl),\n brokenLinks.length === 0 ? 'success' : 'warning',\n brokenLinks.length === 0\n ? `All ${internalLinks.length} internal links keep the \"${pageLocale}\" locale`\n : {\n message: `${brokenLinks.length} internal links leave the \"${pageLocale}\" locale (${otherLocaleLinks.length} to another locale, ${unlocalizedLinks.length} without locale)`,\n links: listLinks(brokenLinks),\n }\n );\n }\n\n if (routing.locales.length > 1) {\n emitCheck(\n emit,\n urlCheckType('url_hasLangSelector', targetUrl),\n switcherLinks.length > 0 ? 'success' : 'warning',\n switcherLinks.length > 0\n ? {\n message: `${switcherLinks.length} crawlable links to other locale versions`,\n links: listLinks(switcherLinks),\n }\n : 'No crawlable <a href> to the other locale versions: search engines only discover them through hreflang'\n );\n }\n};\n\n/* ------------------------------------------------------------------------ */\n/* Bundle content */\n/* ------------------------------------------------------------------------ */\n\nconst getFilename = (url: string): string =>\n url.split('/').pop()?.split('?')[0] ?? url;\n\nconst formatChunk = (chunk: ChunkAnalysis) => ({\n filename: getFilename(chunk.url),\n url: chunk.url,\n fileSize: formatSize(chunk.fileSize),\n totalLocaleSize: formatSize(chunk.totalLocaleSize),\n usedLocaleSize: formatSize(chunk.usedLocaleSize),\n unusedLocaleSize: formatSize(chunk.unusedLocaleSize),\n dictionariesFound: chunk.dictionariesFound,\n unusedPercent: `${chunk.unusedPercent}%`,\n});\n\n/** Human-readable version of a {@link BundleContentAnalysis}, for display. */\nexport const formatBundleAnalysis = (analysis: BundleContentAnalysis) => ({\n currentLocale: analysis.currentLocale,\n totalPageSize: formatSize(analysis.totalPageSize),\n renderedContentSize: formatSize(analysis.renderedContentSize),\n contentSize: formatSize(analysis.contentSize),\n totalLocaleSize: formatSize(analysis.totalLocaleSize),\n totalUnusedLocaleSize: formatSize(analysis.totalUnusedLocaleSize),\n unusedPercentOfLocale: `${analysis.unusedPercentOfLocale}%`,\n mainBundleChunks: analysis.mainBundleChunks.map(formatChunk),\n lazyBundleChunks: analysis.lazyBundleChunks.map(formatChunk),\n});\n\n/**\n * `url_unusedBundleContent`: share of other-locale translations shipped in the\n * eagerly loaded bundles. Returns the raw analysis for reporting.\n */\nexport const checkBundleContent = (\n chunks: BundleChunkInput[],\n html: string,\n currentLocale: string | undefined,\n targetUrl: string,\n totalPageSize: number,\n emit: EmitScanEvent\n): BundleContentAnalysis | undefined => {\n const type = urlCheckType('url_unusedBundleContent', targetUrl);\n\n if (!currentLocale) {\n emitCheck(\n emit,\n type,\n 'warning',\n 'Cannot analyse bundle content: page locale not detected'\n );\n return undefined;\n }\n\n const analysis = analyzeBundleContent(\n chunks,\n html,\n currentLocale,\n totalPageSize\n );\n\n // Status is driven by the main bundle — lazy chunks with unused content are expected.\n const mainBundleMaxUnused = analysis.mainBundleChunks.reduce(\n (max, chunk) => Math.max(max, chunk.unusedPercent),\n 0\n );\n\n const status: ScanCheckStatus =\n mainBundleMaxUnused === 0\n ? 'success'\n : mainBundleMaxUnused <= 30\n ? 'warning'\n : 'error';\n\n emitCheck(emit, type, status, formatBundleAnalysis(analysis));\n\n return analysis;\n};\n\n/* ------------------------------------------------------------------------ */\n/* robots.txt & sitemap */\n/* ------------------------------------------------------------------------ */\n\n/**\n * Fetch `robots.txt` and check that neither the site nor its localized URLs\n * are blocked for Googlebot.\n *\n * - `robots_robotsPresent`\n * - `robots_noLocalizedUrlsForgotten`\n *\n * @param localizedUrls - URLs of every locale version (hreflang alternates).\n * @returns The sitemaps declared in `robots.txt`.\n */\nexport const checkRobots = async (\n origin: string,\n localizedUrls: string[],\n fetchOptions: ScanFetchOptions,\n emit: EmitScanEvent\n): Promise<string[]> => {\n const response = await fetchText(`${origin}/robots.txt`, fetchOptions);\n\n if (!response?.ok) {\n emitCheck(\n emit,\n 'robots_robotsPresent',\n 'warning',\n response\n ? `No robots.txt found (HTTP ${response.status})`\n : 'robots.txt is unreachable'\n );\n return [];\n }\n\n const robots = parseRobots(response.text);\n emitCheck(emit, 'robots_robotsPresent', 'success', {\n disallow: robots.disallowedPaths,\n sitemaps: robots.sitemapUrls,\n });\n\n const blockedUrls = [origin, ...localizedUrls].filter((url) => {\n const parsedUrl = parseUrl(url);\n return (\n parsedUrl?.origin === origin &&\n !isPathAllowedByRobots(`${parsedUrl.pathname}${parsedUrl.search}`, robots)\n );\n });\n\n const isSiteBlocked = !isPathAllowedByRobots('/', robots);\n emitCheck(\n emit,\n 'robots_noLocalizedUrlsForgotten',\n blockedUrls.length === 0 ? 'success' : 'error',\n blockedUrls.length === 0\n ? true\n : isSiteBlocked\n ? 'robots.txt blocks the whole site for Googlebot (Disallow: /)'\n : {\n message: 'Localized URLs blocked by robots.txt',\n urls: [...new Set(blockedUrls)],\n }\n );\n\n return robots.sitemapUrls;\n};\n\n/**\n * Read the sitemaps (declared in `robots.txt`, else the default locations,\n * following sitemap indexes) and check their locale coverage.\n *\n * - `sitemap_sitemapPresent`\n * - `sitemap_noLocalizedUrlsForgotten`: every locale is listed, and entries\n * declaring alternates declare all of them\n * - `sitemap_hasAlternates` / `sitemap_hasXDefault`\n */\nexport const checkSitemap = async (\n origin: string,\n declaredSitemapUrls: string[],\n routing: RoutingDetection,\n fetchOptions: ScanFetchOptions,\n emit: EmitScanEvent\n): Promise<void> => {\n const { fetchedSitemapUrls, entries, isTruncated } =\n await collectSitemapEntries(\n declaredSitemapUrls.length > 0\n ? declaredSitemapUrls\n : getDefaultSitemapUrls(origin),\n fetchOptions\n );\n\n if (fetchedSitemapUrls.length === 0) {\n emitCheck(\n emit,\n 'sitemap_sitemapPresent',\n 'warning',\n declaredSitemapUrls.length > 0\n ? `Sitemaps declared in robots.txt are unreachable: ${declaredSitemapUrls.join(', ')}`\n : 'No sitemap found (robots.txt Sitemap directive, /sitemap.xml, /sitemap_index.xml)'\n );\n return;\n }\n\n emitCheck(emit, 'sitemap_sitemapPresent', 'success', {\n sitemaps: fetchedSitemapUrls,\n urlCount: entries.length,\n isTruncated,\n });\n\n const knownLocales = routing.locales;\n const foundLanguages = new Set<string>();\n const entriesWithoutSelfReference: string[] = [];\n const relativeAlternates: string[] = [];\n\n let partiallyTranslatedCount = 0;\n let hasAlternates = false;\n let hasXDefault = false;\n\n for (const { loc, alternates } of entries) {\n const locLocale = getUrlLocale(loc, routing);\n if (locLocale) foundLanguages.add(getLanguageCode(locLocale));\n if (alternates.length === 0) continue;\n\n hasAlternates = true;\n const entryLanguages = new Set<string>();\n for (const { hreflang, href } of alternates) {\n if (!isAbsoluteUrl(href)) {\n relativeAlternates.push(`${loc}: ${hreflang} → ${href}`);\n }\n if (hreflang.toLowerCase() === 'x-default') {\n hasXDefault = true;\n continue;\n }\n entryLanguages.add(getLanguageCode(hreflang));\n foundLanguages.add(getLanguageCode(hreflang));\n }\n\n // Google requires each <url> to list itself among its alternates.\n const normalizedLoc = normalizeUrl(loc);\n if (!alternates.some(({ href }) => normalizeUrl(href) === normalizedLoc)) {\n entriesWithoutSelfReference.push(loc);\n }\n if (entryLanguages.size < knownLocales.length) partiallyTranslatedCount++;\n }\n\n // Subdomain / domain sites usually serve one sitemap per host.\n const isSingleOriginRouting =\n routing.strategy !== 'subdomain' && routing.strategy !== 'domain';\n\n if (knownLocales.length > 1) {\n const missingLocales = isSingleOriginRouting\n ? knownLocales.filter(\n (locale) => !foundLanguages.has(getLanguageCode(locale))\n )\n : [];\n const issues = [\n ...(missingLocales.length > 0\n ? [`Locales missing from the sitemap: ${missingLocales.join(', ')}`]\n : []),\n ...entriesWithoutSelfReference\n .slice(0, MAX_LISTED_LINKS)\n .map((loc) => `${loc} is not listed among its own alternates`),\n ];\n emitCheck(\n emit,\n 'sitemap_noLocalizedUrlsForgotten',\n issues.length === 0 ? 'success' : 'warning',\n issues.length === 0\n ? {\n message: `Every locale (${knownLocales.join(', ')}) is listed`,\n partiallyTranslatedUrlCount: partiallyTranslatedCount,\n }\n : issues\n );\n }\n\n if (!hasAlternates) {\n emitCheck(\n emit,\n 'sitemap_hasAlternates',\n 'warning',\n 'No alternate language links found in the sitemap'\n );\n } else if (relativeAlternates.length > 0) {\n emitCheck(emit, 'sitemap_hasAlternates', 'error', {\n message: `${relativeAlternates.length} sitemap hreflang alternates are not absolute URLs (https://…): search engines ignore them`,\n alternates: relativeAlternates.slice(0, MAX_LISTED_LINKS),\n });\n } else {\n emitCheck(emit, 'sitemap_hasAlternates', 'success', true);\n }\n\n emitCheck(\n emit,\n 'sitemap_hasXDefault',\n hasXDefault ? 'success' : 'warning',\n hasXDefault ? true : 'No x-default hreflang in the sitemap'\n );\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/scan/detection/checkDetails.ts
|
|
2
|
+
/**
|
|
3
|
+
* Split the details attached to a scan check into its message, the listed
|
|
4
|
+
* links (HTML source) and the other listed items. Shapes without any of them
|
|
5
|
+
* (bundle summaries, hreflang lists…) return empty lists.
|
|
6
|
+
*
|
|
7
|
+
* @param details - The `success` / `warning` / `error` payload of a check.
|
|
8
|
+
*/
|
|
9
|
+
const splitCheckDetails = (details) => {
|
|
10
|
+
if (typeof details === "string") return {
|
|
11
|
+
message: details,
|
|
12
|
+
links: [],
|
|
13
|
+
items: []
|
|
14
|
+
};
|
|
15
|
+
if (Array.isArray(details)) return {
|
|
16
|
+
links: [],
|
|
17
|
+
items: details.map(String)
|
|
18
|
+
};
|
|
19
|
+
if (!details || typeof details !== "object") return {
|
|
20
|
+
links: [],
|
|
21
|
+
items: []
|
|
22
|
+
};
|
|
23
|
+
const { message, issues, links, alternates, urls } = details;
|
|
24
|
+
const listedItems = [
|
|
25
|
+
issues,
|
|
26
|
+
alternates,
|
|
27
|
+
urls
|
|
28
|
+
].find(Array.isArray);
|
|
29
|
+
return {
|
|
30
|
+
message: typeof message === "string" ? message : void 0,
|
|
31
|
+
links: Array.isArray(links) ? links.map((link) => String(link).replace(/`{3,4}(?:html)?\n?/g, "")) : [],
|
|
32
|
+
items: (listedItems ?? []).map(String)
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Turn the details attached to a scan check into short printable lines: its
|
|
37
|
+
* message, then the listed issues / links / URLs. Shared by the CLI report and
|
|
38
|
+
* the Chrome extension popup.
|
|
39
|
+
*
|
|
40
|
+
* @param details - The `success` / `warning` / `error` payload of a check.
|
|
41
|
+
* @returns The message first, then one line per listed item.
|
|
42
|
+
*/
|
|
43
|
+
const getCheckDetailLines = (details) => {
|
|
44
|
+
const { message, links, items } = splitCheckDetails(details);
|
|
45
|
+
return [
|
|
46
|
+
...message ? [message] : [],
|
|
47
|
+
...links,
|
|
48
|
+
...items
|
|
49
|
+
];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
export { getCheckDetailLines, splitCheckDetails };
|
|
54
|
+
//# sourceMappingURL=checkDetails.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkDetails.mjs","names":[],"sources":["../../../../src/scan/detection/checkDetails.ts"],"sourcesContent":["/** Details of a scan check, split for display. */\nexport type CheckDetailParts = {\n /** Explanation of the check result. */\n message?: string;\n /** Offending `<a>` elements, as HTML source. */\n links: string[];\n /** Other listed items: issues, hreflang alternates, URLs… */\n items: string[];\n};\n\n/**\n * Split the details attached to a scan check into its message, the listed\n * links (HTML source) and the other listed items. Shapes without any of them\n * (bundle summaries, hreflang lists…) return empty lists.\n *\n * @param details - The `success` / `warning` / `error` payload of a check.\n */\nexport const splitCheckDetails = (details: unknown): CheckDetailParts => {\n if (typeof details === 'string') {\n return { message: details, links: [], items: [] };\n }\n if (Array.isArray(details)) {\n return { links: [], items: details.map(String) };\n }\n if (!details || typeof details !== 'object') return { links: [], items: [] };\n\n const { message, issues, links, alternates, urls } = details as Record<\n string,\n unknown\n >;\n const listedItems = [issues, alternates, urls].find(Array.isArray) as\n | unknown[]\n | undefined;\n\n return {\n message: typeof message === 'string' ? message : undefined,\n links: Array.isArray(links)\n ? links.map((link) => String(link).replace(/`{3,4}(?:html)?\\n?/g, ''))\n : [],\n items: (listedItems ?? []).map(String),\n };\n};\n\n/**\n * Turn the details attached to a scan check into short printable lines: its\n * message, then the listed issues / links / URLs. Shared by the CLI report and\n * the Chrome extension popup.\n *\n * @param details - The `success` / `warning` / `error` payload of a check.\n * @returns The message first, then one line per listed item.\n */\nexport const getCheckDetailLines = (details: unknown): string[] => {\n const { message, links, items } = splitCheckDetails(details);\n\n return [...(message ? [message] : []), ...links, ...items];\n};\n"],"mappings":";;;;;;;;AAiBA,MAAa,qBAAqB,YAAuC;CACvE,IAAI,OAAO,YAAY,UACrB,OAAO;EAAE,SAAS;EAAS,OAAO,CAAC;EAAG,OAAO,CAAC;CAAE;CAElD,IAAI,MAAM,QAAQ,OAAO,GACvB,OAAO;EAAE,OAAO,CAAC;EAAG,OAAO,QAAQ,IAAI,MAAM;CAAE;CAEjD,IAAI,CAAC,WAAW,OAAO,YAAY,UAAU,OAAO;EAAE,OAAO,CAAC;EAAG,OAAO,CAAC;CAAE;CAE3E,MAAM,EAAE,SAAS,QAAQ,OAAO,YAAY,SAAS;CAIrD,MAAM,cAAc;EAAC;EAAQ;EAAY;CAAI,CAAC,CAAC,KAAK,MAAM,OAAO;CAIjE,OAAO;EACL,SAAS,OAAO,YAAY,WAAW,UAAU;EACjD,OAAO,MAAM,QAAQ,KAAK,IACtB,MAAM,KAAK,SAAS,OAAO,IAAI,CAAC,CAAC,QAAQ,uBAAuB,EAAE,CAAC,IACnE,CAAC;EACL,QAAQ,eAAe,CAAC,EAAC,CAAE,IAAI,MAAM;CACvC;AACF;;;;;;;;;AAUA,MAAa,uBAAuB,YAA+B;CACjE,MAAM,EAAE,SAAS,OAAO,UAAU,kBAAkB,OAAO;CAE3D,OAAO;EAAC,GAAI,UAAU,CAAC,OAAO,IAAI,CAAC;EAAI,GAAG;EAAO,GAAG;CAAK;AAC3D"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { normalizeHostname, normalizeUrl, parseUrl } from "./url.mjs";
|
|
2
|
+
import { isSameLanguage } from "./localeCode.mjs";
|
|
3
|
+
import { getUrlLocale } from "./detectRoutingStrategy.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/scan/detection/classifyInternalLinks.ts
|
|
6
|
+
const NON_PAGE_EXTENSION_PATTERN = /\.(?:pdf|png|jpe?g|gif|svg|webp|avif|ico|zip|xml|txt|json|css|js|mp4|webm|mp3)$/i;
|
|
7
|
+
/**
|
|
8
|
+
* Group the internal anchors of a page by the locale they lead to, reading
|
|
9
|
+
* each URL through the detected routing strategy (so unprefixed links count as
|
|
10
|
+
* the default locale on a `prefix-no-default` site, and `?lang=` is understood
|
|
11
|
+
* on a `search-params` site).
|
|
12
|
+
*/
|
|
13
|
+
const classifyInternalLinks = ({ targetUrl, anchors, hreflangs, routing, pageLocale }) => {
|
|
14
|
+
const targetHostname = normalizeHostname(new URL(targetUrl).hostname);
|
|
15
|
+
const localeHostnames = new Set(Object.keys(routing.hostLocales ?? {}));
|
|
16
|
+
const otherLocaleAlternateUrls = new Set(hreflangs.filter(({ hreflang }) => hreflang.toLowerCase() !== "x-default" && !isSameLanguage(hreflang, pageLocale)).map(({ href }) => normalizeUrl(href, targetUrl)));
|
|
17
|
+
const classification = {
|
|
18
|
+
internalLinks: [],
|
|
19
|
+
sameLocaleLinks: [],
|
|
20
|
+
otherLocaleLinks: [],
|
|
21
|
+
unlocalizedLinks: [],
|
|
22
|
+
switcherLinks: []
|
|
23
|
+
};
|
|
24
|
+
for (const anchor of anchors) {
|
|
25
|
+
if (/^(?:#|javascript:|mailto:|tel:)/i.test(anchor.href)) continue;
|
|
26
|
+
const url = parseUrl(anchor.href, targetUrl);
|
|
27
|
+
if (!url || !/^https?:$/.test(url.protocol)) continue;
|
|
28
|
+
const hostname = normalizeHostname(url.hostname);
|
|
29
|
+
if (hostname !== targetHostname && !localeHostnames.has(hostname)) continue;
|
|
30
|
+
if (NON_PAGE_EXTENSION_PATTERN.test(url.pathname)) continue;
|
|
31
|
+
if (anchor.hreflang && !isSameLanguage(anchor.hreflang, pageLocale) || otherLocaleAlternateUrls.has(normalizeUrl(url.href))) {
|
|
32
|
+
classification.switcherLinks.push(anchor);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
classification.internalLinks.push(anchor);
|
|
36
|
+
const linkLocale = getUrlLocale(url.href, routing);
|
|
37
|
+
if (!linkLocale) classification.unlocalizedLinks.push(anchor);
|
|
38
|
+
else if (isSameLanguage(linkLocale, pageLocale)) classification.sameLocaleLinks.push(anchor);
|
|
39
|
+
else classification.otherLocaleLinks.push(anchor);
|
|
40
|
+
}
|
|
41
|
+
return classification;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { classifyInternalLinks };
|
|
46
|
+
//# sourceMappingURL=classifyInternalLinks.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classifyInternalLinks.mjs","names":[],"sources":["../../../../src/scan/detection/classifyInternalLinks.ts"],"sourcesContent":["import { getUrlLocale, type RoutingDetection } from './detectRoutingStrategy';\nimport { isSameLanguage } from './localeCode';\nimport { normalizeHostname, normalizeUrl, parseUrl } from './url';\n\n/** A page anchor, as parsed from HTML or read from a live DOM. */\nexport type LinkAnchor = {\n href: string;\n text: string;\n /** `hreflang` attribute, set on language-switcher links. */\n hreflang?: string;\n};\n\n/** Input of {@link classifyInternalLinks}. */\nexport type LinkClassificationInput<Anchor extends LinkAnchor> = {\n /** Absolute URL of the page holding the anchors. */\n targetUrl: string;\n anchors: Anchor[];\n hreflangs: { hreflang: string; href: string }[];\n routing: RoutingDetection;\n /** Locale of the page. */\n pageLocale?: string;\n};\n\n/** Internal anchors of a page, grouped by the locale they lead to. */\nexport type LinkClassification<Anchor extends LinkAnchor> = {\n /** Internal page links, language-switcher links excluded. */\n internalLinks: Anchor[];\n /** Internal links keeping the page locale. */\n sameLocaleLinks: Anchor[];\n /** Internal links leading to another locale. */\n otherLocaleLinks: Anchor[];\n /** Internal links whose URL carries no locale. */\n unlocalizedLinks: Anchor[];\n /** Links to the other locale versions (hreflang alternates). */\n switcherLinks: Anchor[];\n};\n\nconst NON_PAGE_EXTENSION_PATTERN =\n /\\.(?:pdf|png|jpe?g|gif|svg|webp|avif|ico|zip|xml|txt|json|css|js|mp4|webm|mp3)$/i;\n\n/**\n * Group the internal anchors of a page by the locale they lead to, reading\n * each URL through the detected routing strategy (so unprefixed links count as\n * the default locale on a `prefix-no-default` site, and `?lang=` is understood\n * on a `search-params` site).\n */\nexport const classifyInternalLinks = <Anchor extends LinkAnchor>({\n targetUrl,\n anchors,\n hreflangs,\n routing,\n pageLocale,\n}: LinkClassificationInput<Anchor>): LinkClassification<Anchor> => {\n const targetHostname = normalizeHostname(new URL(targetUrl).hostname);\n const localeHostnames = new Set(Object.keys(routing.hostLocales ?? {}));\n const otherLocaleAlternateUrls = new Set(\n hreflangs\n .filter(\n ({ hreflang }) =>\n hreflang.toLowerCase() !== 'x-default' &&\n !isSameLanguage(hreflang, pageLocale)\n )\n .map(({ href }) => normalizeUrl(href, targetUrl))\n );\n\n const classification: LinkClassification<Anchor> = {\n internalLinks: [],\n sameLocaleLinks: [],\n otherLocaleLinks: [],\n unlocalizedLinks: [],\n switcherLinks: [],\n };\n\n for (const anchor of anchors) {\n if (/^(?:#|javascript:|mailto:|tel:)/i.test(anchor.href)) continue;\n const url = parseUrl(anchor.href, targetUrl);\n if (!url || !/^https?:$/.test(url.protocol)) continue;\n\n const hostname = normalizeHostname(url.hostname);\n if (hostname !== targetHostname && !localeHostnames.has(hostname)) continue;\n if (NON_PAGE_EXTENSION_PATTERN.test(url.pathname)) continue;\n\n if (\n (anchor.hreflang && !isSameLanguage(anchor.hreflang, pageLocale)) ||\n otherLocaleAlternateUrls.has(normalizeUrl(url.href))\n ) {\n classification.switcherLinks.push(anchor);\n continue;\n }\n\n classification.internalLinks.push(anchor);\n const linkLocale = getUrlLocale(url.href, routing);\n if (!linkLocale) {\n classification.unlocalizedLinks.push(anchor);\n } else if (isSameLanguage(linkLocale, pageLocale)) {\n classification.sameLocaleLinks.push(anchor);\n } else {\n classification.otherLocaleLinks.push(anchor);\n }\n }\n\n return classification;\n};\n"],"mappings":";;;;;AAqCA,MAAM,6BACJ;;;;;;;AAQF,MAAa,yBAAoD,EAC/D,WACA,SACA,WACA,SACA,iBACiE;CACjE,MAAM,iBAAiB,kBAAkB,IAAI,IAAI,SAAS,CAAC,CAAC,QAAQ;CACpE,MAAM,kBAAkB,IAAI,IAAI,OAAO,KAAK,QAAQ,eAAe,CAAC,CAAC,CAAC;CACtE,MAAM,2BAA2B,IAAI,IACnC,UACG,QACE,EAAE,eACD,SAAS,YAAY,MAAM,eAC3B,CAAC,eAAe,UAAU,UAAU,CACxC,CAAC,CACA,KAAK,EAAE,WAAW,aAAa,MAAM,SAAS,CAAC,CACpD;CAEA,MAAM,iBAA6C;EACjD,eAAe,CAAC;EAChB,iBAAiB,CAAC;EAClB,kBAAkB,CAAC;EACnB,kBAAkB,CAAC;EACnB,eAAe,CAAC;CAClB;CAEA,KAAK,MAAM,UAAU,SAAS;EAC5B,IAAI,mCAAmC,KAAK,OAAO,IAAI,GAAG;EAC1D,MAAM,MAAM,SAAS,OAAO,MAAM,SAAS;EAC3C,IAAI,CAAC,OAAO,CAAC,YAAY,KAAK,IAAI,QAAQ,GAAG;EAE7C,MAAM,WAAW,kBAAkB,IAAI,QAAQ;EAC/C,IAAI,aAAa,kBAAkB,CAAC,gBAAgB,IAAI,QAAQ,GAAG;EACnE,IAAI,2BAA2B,KAAK,IAAI,QAAQ,GAAG;EAEnD,IACG,OAAO,YAAY,CAAC,eAAe,OAAO,UAAU,UAAU,KAC/D,yBAAyB,IAAI,aAAa,IAAI,IAAI,CAAC,GACnD;GACA,eAAe,cAAc,KAAK,MAAM;GACxC;EACF;EAEA,eAAe,cAAc,KAAK,MAAM;EACxC,MAAM,aAAa,aAAa,IAAI,MAAM,OAAO;EACjD,IAAI,CAAC,YACH,eAAe,iBAAiB,KAAK,MAAM;OACtC,IAAI,eAAe,YAAY,UAAU,GAC9C,eAAe,gBAAgB,KAAK,MAAM;OAE1C,eAAe,iBAAiB,KAAK,MAAM;CAE/C;CAEA,OAAO;AACT"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { getFirstPathSegment, normalizeHostname, normalizeUrl, parseUrl } from "./url.mjs";
|
|
2
|
+
import { findMatchingLocale, looksLikeLocaleCode } from "./localeCode.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/scan/detection/detectRoutingStrategy.ts
|
|
5
|
+
/** Locale of a URL according to an already-detected routing strategy. */
|
|
6
|
+
const getUrlLocale = (url, routing) => {
|
|
7
|
+
const parsedUrl = parseUrl(url);
|
|
8
|
+
if (!parsedUrl) return void 0;
|
|
9
|
+
switch (routing.strategy) {
|
|
10
|
+
case "prefix-all":
|
|
11
|
+
case "prefix-no-default": return findMatchingLocale(getFirstPathSegment(parsedUrl.pathname), routing.locales) ?? (routing.strategy === "prefix-no-default" ? routing.defaultLocale : void 0);
|
|
12
|
+
case "search-params": return findMatchingLocale(parsedUrl.searchParams.get(routing.searchParamName ?? ""), routing.locales) ?? routing.defaultLocale;
|
|
13
|
+
case "subdomain":
|
|
14
|
+
case "domain": return routing.hostLocales?.[normalizeHostname(parsedUrl.hostname)] ?? findMatchingLocale(parsedUrl.hostname.split(".")[0], routing.locales);
|
|
15
|
+
default: return;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Detect the strategy from at least two hreflang alternates. */
|
|
19
|
+
const detectFromAlternates = (localizedUrls, locales, xDefaultUrl) => {
|
|
20
|
+
const alternateCount = localizedUrls.length;
|
|
21
|
+
const findDefaultLocale = (unmarked) => unmarked.length === 1 ? unmarked[0]?.locale : localizedUrls.find(({ url }) => xDefaultUrl && normalizeUrl(url.href) === normalizeUrl(xDefaultUrl))?.locale;
|
|
22
|
+
const hostnames = new Set(localizedUrls.map(({ url }) => normalizeHostname(url.hostname)));
|
|
23
|
+
if (hostnames.size > 1) {
|
|
24
|
+
const hostLocales = Object.fromEntries(localizedUrls.map(({ url, locale }) => [normalizeHostname(url.hostname), locale]));
|
|
25
|
+
const subdomainPrefixed = localizedUrls.filter(({ url, locale }) => findMatchingLocale(url.hostname.split(".")[0], [locale]) !== void 0);
|
|
26
|
+
const isSubdomain = subdomainPrefixed.length * 2 >= localizedUrls.length;
|
|
27
|
+
return {
|
|
28
|
+
strategy: isSubdomain ? "subdomain" : "domain",
|
|
29
|
+
confidence: "high",
|
|
30
|
+
hostLocales,
|
|
31
|
+
defaultLocale: isSubdomain ? findDefaultLocale(localizedUrls.filter((entry) => !subdomainPrefixed.includes(entry))) : void 0,
|
|
32
|
+
evidence: `hreflang alternates use ${hostnames.size} different hosts`
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const parameterMatchCount = /* @__PURE__ */ new Map();
|
|
36
|
+
for (const { url, locale } of localizedUrls) for (const [parameterName, parameterValue] of url.searchParams) if (findMatchingLocale(parameterValue, [locale])) parameterMatchCount.set(parameterName, (parameterMatchCount.get(parameterName) ?? 0) + 1);
|
|
37
|
+
const [searchParamName, searchParamCount] = [...parameterMatchCount.entries()].sort((first, second) => second[1] - first[1])[0] ?? ["", 0];
|
|
38
|
+
if (searchParamCount >= Math.max(alternateCount - 1, 1)) return {
|
|
39
|
+
strategy: "search-params",
|
|
40
|
+
confidence: "high",
|
|
41
|
+
searchParamName,
|
|
42
|
+
defaultLocale: findDefaultLocale(localizedUrls.filter(({ url }) => !url.searchParams.has(searchParamName))),
|
|
43
|
+
evidence: `hreflang alternates carry the locale in the "?${searchParamName}=" parameter`
|
|
44
|
+
};
|
|
45
|
+
const unprefixed = localizedUrls.filter(({ url, locale }) => !findMatchingLocale(getFirstPathSegment(url.pathname), [locale]));
|
|
46
|
+
if (unprefixed.length === 0) return {
|
|
47
|
+
strategy: "prefix-all",
|
|
48
|
+
confidence: "high",
|
|
49
|
+
defaultLocale: findDefaultLocale([]),
|
|
50
|
+
evidence: "every hreflang alternate starts with a locale segment"
|
|
51
|
+
};
|
|
52
|
+
if (unprefixed.length === 1) return {
|
|
53
|
+
strategy: "prefix-no-default",
|
|
54
|
+
confidence: "high",
|
|
55
|
+
defaultLocale: unprefixed[0]?.locale,
|
|
56
|
+
evidence: `every hreflang alternate but "${unprefixed[0]?.locale}" starts with a locale segment`
|
|
57
|
+
};
|
|
58
|
+
if (new Set(localizedUrls.map(({ url }) => normalizeUrl(url.href))).size === 1) return {
|
|
59
|
+
strategy: "no-prefix",
|
|
60
|
+
confidence: "high",
|
|
61
|
+
evidence: `all ${locales.length} hreflang alternates point to the same URL`
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
strategy: "unknown",
|
|
65
|
+
confidence: "high",
|
|
66
|
+
evidence: "hreflang alternates use different URLs without a locale marker (localized slugs?)"
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
/** Guess the strategy from the scanned URL when no alternates are declared. */
|
|
70
|
+
const detectFromPageUrl = (pageUrl, htmlLang) => {
|
|
71
|
+
const firstSegment = getFirstPathSegment(pageUrl.pathname);
|
|
72
|
+
const pageLocales = htmlLang ? [htmlLang] : [];
|
|
73
|
+
if (firstSegment && (findMatchingLocale(firstSegment, pageLocales) || !htmlLang && looksLikeLocaleCode(firstSegment))) return {
|
|
74
|
+
strategy: "prefix-all",
|
|
75
|
+
confidence: "low",
|
|
76
|
+
evidence: `the page URL starts with the locale segment "/${firstSegment}"`
|
|
77
|
+
};
|
|
78
|
+
for (const [parameterName, parameterValue] of pageUrl.searchParams) if (findMatchingLocale(parameterValue, pageLocales)) return {
|
|
79
|
+
strategy: "search-params",
|
|
80
|
+
confidence: "low",
|
|
81
|
+
searchParamName: parameterName,
|
|
82
|
+
evidence: `the page URL carries the locale in "?${parameterName}="`
|
|
83
|
+
};
|
|
84
|
+
if (findMatchingLocale(pageUrl.hostname.split(".")[0], pageLocales)) return {
|
|
85
|
+
strategy: "subdomain",
|
|
86
|
+
confidence: "low",
|
|
87
|
+
hostLocales: { [normalizeHostname(pageUrl.hostname)]: htmlLang ?? "" },
|
|
88
|
+
evidence: "the page hostname starts with the locale"
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
strategy: htmlLang ? "no-prefix" : "unknown",
|
|
92
|
+
confidence: "low",
|
|
93
|
+
evidence: htmlLang ? "no hreflang alternates and no locale marker in the page URL" : "no hreflang alternates, no lang attribute and no locale marker in the URL"
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Detect how the site encodes the locale in its URLs (path prefix, query
|
|
98
|
+
* parameter, subdomain, domain or nothing).
|
|
99
|
+
*
|
|
100
|
+
* The hreflang alternates are the most reliable signal: each one pairs a
|
|
101
|
+
* locale with its URL, so the shared pattern reveals the strategy. Without
|
|
102
|
+
* alternates the scanned URL alone is used, with `confidence: 'low'`.
|
|
103
|
+
*/
|
|
104
|
+
const detectRoutingStrategy = ({ pageUrl, htmlLang, hreflangs }) => {
|
|
105
|
+
const parsedPageUrl = parseUrl(pageUrl);
|
|
106
|
+
const localizedUrls = [];
|
|
107
|
+
for (const { hreflang, href } of hreflangs) {
|
|
108
|
+
if (hreflang.toLowerCase() === "x-default") continue;
|
|
109
|
+
const url = parseUrl(href, pageUrl);
|
|
110
|
+
if (url) localizedUrls.push({
|
|
111
|
+
locale: hreflang,
|
|
112
|
+
url
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const xDefaultHref = hreflangs.find(({ hreflang }) => hreflang.toLowerCase() === "x-default")?.href;
|
|
116
|
+
const xDefaultUrl = xDefaultHref ? parseUrl(xDefaultHref, pageUrl)?.href : void 0;
|
|
117
|
+
const locales = [...new Set(localizedUrls.map(({ locale }) => locale))];
|
|
118
|
+
const detection = localizedUrls.length >= 2 ? {
|
|
119
|
+
...detectFromAlternates(localizedUrls, locales, xDefaultUrl),
|
|
120
|
+
locales
|
|
121
|
+
} : {
|
|
122
|
+
...parsedPageUrl ? detectFromPageUrl(parsedPageUrl, htmlLang) : {
|
|
123
|
+
strategy: "unknown",
|
|
124
|
+
confidence: "low",
|
|
125
|
+
evidence: "invalid page URL"
|
|
126
|
+
},
|
|
127
|
+
locales: htmlLang ? [htmlLang] : locales
|
|
128
|
+
};
|
|
129
|
+
if (detection.strategy === "prefix-all" && detection.locales.length === 0) {
|
|
130
|
+
const firstSegment = parsedPageUrl ? getFirstPathSegment(parsedPageUrl.pathname) : void 0;
|
|
131
|
+
if (firstSegment) detection.locales = [firstSegment];
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...detection,
|
|
135
|
+
urlLocale: getUrlLocale(pageUrl, detection)
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
//#endregion
|
|
140
|
+
export { detectRoutingStrategy, getUrlLocale };
|
|
141
|
+
//# sourceMappingURL=detectRoutingStrategy.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detectRoutingStrategy.mjs","names":[],"sources":["../../../../src/scan/detection/detectRoutingStrategy.ts"],"sourcesContent":["import { findMatchingLocale, looksLikeLocaleCode } from './localeCode';\nimport {\n getFirstPathSegment,\n normalizeHostname,\n normalizeUrl,\n parseUrl,\n} from './url';\n\n/**\n * How a website exposes its locales in URLs. Mirrors Intlayer's\n * `routing.mode` values, plus the domain-based strategies.\n *\n * - `prefix-all`: every locale is prefixed (`/en/about`, `/fr/about`)\n * - `prefix-no-default`: the default locale is not prefixed (`/about`, `/fr/about`)\n * - `search-params`: the locale is a query parameter (`/about?lang=fr`)\n * - `subdomain`: the locale is a subdomain (`fr.example.com`)\n * - `domain`: one domain per locale (`example.fr`, `example.de`)\n * - `no-prefix`: a single URL serves every locale (cookie / header based)\n * - `unknown`: not enough signals to decide\n */\nexport type RoutingStrategy =\n | 'prefix-all'\n | 'prefix-no-default'\n | 'search-params'\n | 'subdomain'\n | 'domain'\n | 'no-prefix'\n | 'unknown';\n\n/** Result of {@link detectRoutingStrategy}. */\nexport type RoutingDetection = {\n strategy: RoutingStrategy;\n /**\n * `high` when inferred from the hreflang alternates, `low` when guessed from\n * the scanned URL alone.\n */\n confidence: 'high' | 'low';\n /** Locales known to the site (hreflang values, else the page `lang`). */\n locales: string[];\n /** Locale served without any marker (`prefix-no-default`, `search-params`…). */\n defaultLocale?: string;\n /** Query parameter carrying the locale, for `search-params`. */\n searchParamName?: string;\n /** Hostname → locale map, for `subdomain` / `domain`. */\n hostLocales?: Record<string, string>;\n /** Locale of the scanned page according to its URL alone. */\n urlLocale?: string;\n /** Human-readable explanation of the decision. */\n evidence: string;\n};\n\n/** Input of {@link detectRoutingStrategy}. */\nexport type RoutingDetectionInput = {\n /** Absolute URL of the scanned page. */\n pageUrl: string;\n /** Value of `<html lang>`, when present. */\n htmlLang?: string;\n /** `<link rel=\"alternate\" hreflang>` entries of the page. */\n hreflangs: { hreflang: string; href: string }[];\n};\n\ntype LocalizedUrl = { locale: string; url: URL };\n\n/** Locale of a URL according to an already-detected routing strategy. */\nexport const getUrlLocale = (\n url: string,\n routing: RoutingDetection\n): string | undefined => {\n const parsedUrl = parseUrl(url);\n if (!parsedUrl) return undefined;\n\n switch (routing.strategy) {\n case 'prefix-all':\n case 'prefix-no-default':\n return (\n findMatchingLocale(\n getFirstPathSegment(parsedUrl.pathname),\n routing.locales\n ) ??\n (routing.strategy === 'prefix-no-default'\n ? routing.defaultLocale\n : undefined)\n );\n case 'search-params':\n return (\n findMatchingLocale(\n parsedUrl.searchParams.get(routing.searchParamName ?? ''),\n routing.locales\n ) ?? routing.defaultLocale\n );\n case 'subdomain':\n case 'domain':\n return (\n routing.hostLocales?.[normalizeHostname(parsedUrl.hostname)] ??\n findMatchingLocale(parsedUrl.hostname.split('.')[0], routing.locales)\n );\n default:\n return undefined;\n }\n};\n\n/** Detect the strategy from at least two hreflang alternates. */\nconst detectFromAlternates = (\n localizedUrls: LocalizedUrl[],\n locales: string[],\n xDefaultUrl: string | undefined\n): Omit<RoutingDetection, 'urlLocale' | 'locales'> => {\n const alternateCount = localizedUrls.length;\n const findDefaultLocale = (unmarked: LocalizedUrl[]): string | undefined =>\n unmarked.length === 1\n ? unmarked[0]?.locale\n : localizedUrls.find(\n ({ url }) =>\n xDefaultUrl && normalizeUrl(url.href) === normalizeUrl(xDefaultUrl)\n )?.locale;\n\n // One host per locale → subdomain or domain based routing.\n const hostnames = new Set(\n localizedUrls.map(({ url }) => normalizeHostname(url.hostname))\n );\n if (hostnames.size > 1) {\n const hostLocales = Object.fromEntries(\n localizedUrls.map(({ url, locale }) => [\n normalizeHostname(url.hostname),\n locale,\n ])\n );\n const subdomainPrefixed = localizedUrls.filter(\n ({ url, locale }) =>\n findMatchingLocale(url.hostname.split('.')[0], [locale]) !== undefined\n );\n // Most hosts must start with their locale: `www.example.de` is a domain.\n const isSubdomain = subdomainPrefixed.length * 2 >= localizedUrls.length;\n return {\n strategy: isSubdomain ? 'subdomain' : 'domain',\n confidence: 'high',\n hostLocales,\n defaultLocale: isSubdomain\n ? findDefaultLocale(\n localizedUrls.filter((entry) => !subdomainPrefixed.includes(entry))\n )\n : undefined,\n evidence: `hreflang alternates use ${hostnames.size} different hosts`,\n };\n }\n\n // A query parameter whose value is the locale on (almost) every alternate.\n const parameterMatchCount = new Map<string, number>();\n for (const { url, locale } of localizedUrls) {\n for (const [parameterName, parameterValue] of url.searchParams) {\n if (findMatchingLocale(parameterValue, [locale])) {\n parameterMatchCount.set(\n parameterName,\n (parameterMatchCount.get(parameterName) ?? 0) + 1\n );\n }\n }\n }\n const [searchParamName, searchParamCount] = [\n ...parameterMatchCount.entries(),\n ].sort((first, second) => second[1] - first[1])[0] ?? ['', 0];\n if (searchParamCount >= Math.max(alternateCount - 1, 1)) {\n return {\n strategy: 'search-params',\n confidence: 'high',\n searchParamName,\n defaultLocale: findDefaultLocale(\n localizedUrls.filter(\n ({ url }) => !url.searchParams.has(searchParamName)\n )\n ),\n evidence: `hreflang alternates carry the locale in the \"?${searchParamName}=\" parameter`,\n };\n }\n\n // A locale path prefix on every alternate, or on every one but the default.\n const unprefixed = localizedUrls.filter(\n ({ url, locale }) =>\n !findMatchingLocale(getFirstPathSegment(url.pathname), [locale])\n );\n if (unprefixed.length === 0) {\n return {\n strategy: 'prefix-all',\n confidence: 'high',\n defaultLocale: findDefaultLocale([]),\n evidence: 'every hreflang alternate starts with a locale segment',\n };\n }\n if (unprefixed.length === 1) {\n return {\n strategy: 'prefix-no-default',\n confidence: 'high',\n defaultLocale: unprefixed[0]?.locale,\n evidence: `every hreflang alternate but \"${unprefixed[0]?.locale}\" starts with a locale segment`,\n };\n }\n\n const distinctUrls = new Set(\n localizedUrls.map(({ url }) => normalizeUrl(url.href))\n );\n if (distinctUrls.size === 1) {\n return {\n strategy: 'no-prefix',\n confidence: 'high',\n evidence: `all ${locales.length} hreflang alternates point to the same URL`,\n };\n }\n\n return {\n strategy: 'unknown',\n confidence: 'high',\n evidence:\n 'hreflang alternates use different URLs without a locale marker (localized slugs?)',\n };\n};\n\n/** Guess the strategy from the scanned URL when no alternates are declared. */\nconst detectFromPageUrl = (\n pageUrl: URL,\n htmlLang: string | undefined\n): Omit<RoutingDetection, 'urlLocale' | 'locales'> => {\n const firstSegment = getFirstPathSegment(pageUrl.pathname);\n const pageLocales = htmlLang ? [htmlLang] : [];\n\n if (\n firstSegment &&\n (findMatchingLocale(firstSegment, pageLocales) ||\n (!htmlLang && looksLikeLocaleCode(firstSegment)))\n ) {\n return {\n strategy: 'prefix-all',\n confidence: 'low',\n evidence: `the page URL starts with the locale segment \"/${firstSegment}\"`,\n };\n }\n\n for (const [parameterName, parameterValue] of pageUrl.searchParams) {\n if (findMatchingLocale(parameterValue, pageLocales)) {\n return {\n strategy: 'search-params',\n confidence: 'low',\n searchParamName: parameterName,\n evidence: `the page URL carries the locale in \"?${parameterName}=\"`,\n };\n }\n }\n\n if (findMatchingLocale(pageUrl.hostname.split('.')[0], pageLocales)) {\n return {\n strategy: 'subdomain',\n confidence: 'low',\n hostLocales: { [normalizeHostname(pageUrl.hostname)]: htmlLang ?? '' },\n evidence: 'the page hostname starts with the locale',\n };\n }\n\n return {\n strategy: htmlLang ? 'no-prefix' : 'unknown',\n confidence: 'low',\n evidence: htmlLang\n ? 'no hreflang alternates and no locale marker in the page URL'\n : 'no hreflang alternates, no lang attribute and no locale marker in the URL',\n };\n};\n\n/**\n * Detect how the site encodes the locale in its URLs (path prefix, query\n * parameter, subdomain, domain or nothing).\n *\n * The hreflang alternates are the most reliable signal: each one pairs a\n * locale with its URL, so the shared pattern reveals the strategy. Without\n * alternates the scanned URL alone is used, with `confidence: 'low'`.\n */\nexport const detectRoutingStrategy = ({\n pageUrl,\n htmlLang,\n hreflangs,\n}: RoutingDetectionInput): RoutingDetection => {\n const parsedPageUrl = parseUrl(pageUrl);\n\n const localizedUrls: LocalizedUrl[] = [];\n for (const { hreflang, href } of hreflangs) {\n if (hreflang.toLowerCase() === 'x-default') continue;\n const url = parseUrl(href, pageUrl);\n if (url) localizedUrls.push({ locale: hreflang, url });\n }\n const xDefaultHref = hreflangs.find(\n ({ hreflang }) => hreflang.toLowerCase() === 'x-default'\n )?.href;\n const xDefaultUrl = xDefaultHref\n ? parseUrl(xDefaultHref, pageUrl)?.href\n : undefined;\n\n const locales = [...new Set(localizedUrls.map(({ locale }) => locale))];\n\n const detection: RoutingDetection =\n localizedUrls.length >= 2\n ? {\n ...detectFromAlternates(localizedUrls, locales, xDefaultUrl),\n locales,\n }\n : {\n ...(parsedPageUrl\n ? detectFromPageUrl(parsedPageUrl, htmlLang)\n : {\n strategy: 'unknown',\n confidence: 'low',\n evidence: 'invalid page URL',\n }),\n locales: htmlLang ? [htmlLang] : locales,\n };\n\n if (detection.strategy === 'prefix-all' && detection.locales.length === 0) {\n const firstSegment = parsedPageUrl\n ? getFirstPathSegment(parsedPageUrl.pathname)\n : undefined;\n if (firstSegment) detection.locales = [firstSegment];\n }\n\n return { ...detection, urlLocale: getUrlLocale(pageUrl, detection) };\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { technologySignatures } from "./technologySignatures.mjs";
|
|
2
|
+
import { getSiteDomain, parseUrl } from "./url.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/scan/detection/detectTechnologies.ts
|
|
5
|
+
/** Largest script prefix scanned per chunk, to bound regex cost. */
|
|
6
|
+
const MAX_SCRIPT_LENGTH = 5e6;
|
|
7
|
+
/** Shorten a matched snippet for display in the evidence. */
|
|
8
|
+
const truncate = (text, maxLength = 60) => text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
9
|
+
/** Return the first signal of `signature` found in `input`, if any. */
|
|
10
|
+
const matchSignature = (signature, input, lowerCaseStorageKeys) => {
|
|
11
|
+
const versionFromGlobal = signature.versionGlobal ? input.globalVersions?.[signature.versionGlobal] : void 0;
|
|
12
|
+
for (const globalName of signature.globals ?? []) if (input.globals?.includes(globalName)) return {
|
|
13
|
+
evidence: `window.${globalName}`,
|
|
14
|
+
version: versionFromGlobal
|
|
15
|
+
};
|
|
16
|
+
if (versionFromGlobal) return {
|
|
17
|
+
evidence: `window.${signature.versionGlobal}`,
|
|
18
|
+
version: versionFromGlobal
|
|
19
|
+
};
|
|
20
|
+
for (const domMarker of signature.domMarkers ?? []) if (input.domMarkers?.includes(domMarker)) return { evidence: `${domMarker} DOM marker` };
|
|
21
|
+
for (const storageKey of signature.storageKeys ?? []) if (lowerCaseStorageKeys.has(storageKey.toLowerCase())) return { evidence: `"${storageKey}" cookie / storage key` };
|
|
22
|
+
for (const pattern of signature.html ?? []) {
|
|
23
|
+
const match = input.html?.match(pattern);
|
|
24
|
+
if (match) return {
|
|
25
|
+
evidence: `HTML contains "${truncate(match[0])}"`,
|
|
26
|
+
version: match[1]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const isThirdPartyService = signature.category === "tms" || signature.category === "translation-proxy";
|
|
30
|
+
const pageHostname = input.pageUrl ? parseUrl(input.pageUrl)?.hostname : void 0;
|
|
31
|
+
const isOwnSite = (url) => {
|
|
32
|
+
const hostname = parseUrl(url)?.hostname;
|
|
33
|
+
return Boolean(pageHostname && hostname) && getSiteDomain(hostname) === getSiteDomain(pageHostname);
|
|
34
|
+
};
|
|
35
|
+
for (const pattern of signature.resourceUrls ?? []) {
|
|
36
|
+
const resourceUrl = input.resourceUrls?.find((url) => pattern.test(url) && !(isThirdPartyService && isOwnSite(url)));
|
|
37
|
+
if (resourceUrl) return { evidence: `loads ${truncate(resourceUrl, 90)}` };
|
|
38
|
+
}
|
|
39
|
+
for (const pattern of signature.bundle ?? []) for (const script of input.scripts ?? []) {
|
|
40
|
+
const match = script.slice(0, MAX_SCRIPT_LENGTH).match(pattern);
|
|
41
|
+
if (match) return {
|
|
42
|
+
evidence: `JavaScript bundle contains "${truncate(match[0])}"`,
|
|
43
|
+
version: match[1]
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Every window global and version path referenced by the signatures. Live-page
|
|
49
|
+
* collectors (extension, puppeteer) read these and pass back the ones present.
|
|
50
|
+
*/
|
|
51
|
+
const getTechnologyGlobalNames = () => ({
|
|
52
|
+
globals: [...new Set(technologySignatures.flatMap((signature) => signature.globals ?? []))],
|
|
53
|
+
versionGlobals: technologySignatures.flatMap((signature) => signature.versionGlobal ? [signature.versionGlobal] : [])
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Detect the frameworks, i18n libraries, TMS and translation proxies used by a
|
|
57
|
+
* page from the raw signals collected on it.
|
|
58
|
+
*
|
|
59
|
+
* @param input - The {@link TechnologyDetectionInput} signals.
|
|
60
|
+
* @param signatures - Fingerprints to test, defaults to {@link technologySignatures}.
|
|
61
|
+
* @returns Detected technologies, in signature order.
|
|
62
|
+
*/
|
|
63
|
+
const detectTechnologies = (input, signatures = technologySignatures) => {
|
|
64
|
+
const lowerCaseStorageKeys = new Set((input.storageKeys ?? []).map((key) => key.toLowerCase()));
|
|
65
|
+
const matchedTechnologies = [];
|
|
66
|
+
for (const signature of signatures) {
|
|
67
|
+
const match = matchSignature(signature, input, lowerCaseStorageKeys);
|
|
68
|
+
if (!match) continue;
|
|
69
|
+
matchedTechnologies.push({
|
|
70
|
+
id: signature.id,
|
|
71
|
+
name: signature.name,
|
|
72
|
+
category: signature.category,
|
|
73
|
+
version: match.version || void 0,
|
|
74
|
+
evidence: match.evidence
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const signatureById = new Map(signatures.map((signature) => [signature.id, signature]));
|
|
78
|
+
const getIds = (technologies) => new Set(technologies.map(({ id }) => id));
|
|
79
|
+
const matchedIds = getIds(matchedTechnologies);
|
|
80
|
+
const supportedTechnologies = matchedTechnologies.filter(({ id }) => !signatureById.get(id)?.requires?.some((requiredId) => !matchedIds.has(requiredId)));
|
|
81
|
+
const supportedIds = getIds(supportedTechnologies);
|
|
82
|
+
return supportedTechnologies.filter(({ id }) => !signatureById.get(id)?.hiddenBy?.some((hidingId) => supportedIds.has(hidingId)));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
export { detectTechnologies, getTechnologyGlobalNames };
|
|
87
|
+
//# sourceMappingURL=detectTechnologies.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detectTechnologies.mjs","names":[],"sources":["../../../../src/scan/detection/detectTechnologies.ts"],"sourcesContent":["import {\n type TechnologyCategory,\n type TechnologySignature,\n technologySignatures,\n} from './technologySignatures';\nimport { getSiteDomain, parseUrl } from './url';\n\n/** A technology detected on the scanned page. */\nexport type DetectedTechnology = {\n /** Stable identifier, e.g. `nextjs`, `crowdin`. */\n id: string;\n /** Human-readable name, e.g. `Next.js`. */\n name: string;\n category: TechnologyCategory;\n /** Version when it can be read from the page, e.g. `14.2.3`. */\n version?: string;\n /** Short explanation of the signal that triggered the detection. */\n evidence: string;\n};\n\n/**\n * Raw signals collected from a page. Every field is optional so each consumer\n * provides what it can: the CLI basic mode only has HTML + scripts, the\n * backend and the extension also read window globals and storage keys.\n */\nexport type TechnologyDetectionInput = {\n /**\n * URL of the scanned page. Resource URLs on its own site are ignored for\n * third-party services, so the Crowdin website is not reported as using\n * Crowdin because it loads its own favicon.\n */\n pageUrl?: string;\n /** HTML document, inline scripts included. */\n html?: string;\n /** Script / stylesheet / iframe / network request URLs. */\n resourceUrls?: string[];\n /** Contents of the same-origin JavaScript chunks. */\n scripts?: string[];\n /** Names of the window globals that exist on the page. */\n globals?: string[];\n /** Cookie / web-storage keys found on the page. */\n storageKeys?: string[];\n /** Live-DOM markers, see {@link TechnologySignature.domMarkers}. */\n domMarkers?: string[];\n /** Values read from {@link TechnologySignature.versionGlobal} paths. */\n globalVersions?: Record<string, string>;\n};\n\n/** Largest script prefix scanned per chunk, to bound regex cost. */\nconst MAX_SCRIPT_LENGTH = 5_000_000;\n\n/** Shorten a matched snippet for display in the evidence. */\nconst truncate = (text: string, maxLength = 60): string =>\n text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;\n\ntype SignatureMatch = { evidence: string; version?: string };\n\n/** Return the first signal of `signature` found in `input`, if any. */\nconst matchSignature = (\n signature: TechnologySignature,\n input: TechnologyDetectionInput,\n lowerCaseStorageKeys: Set<string>\n): SignatureMatch | undefined => {\n const versionFromGlobal = signature.versionGlobal\n ? input.globalVersions?.[signature.versionGlobal]\n : undefined;\n\n for (const globalName of signature.globals ?? []) {\n if (input.globals?.includes(globalName)) {\n return { evidence: `window.${globalName}`, version: versionFromGlobal };\n }\n }\n\n if (versionFromGlobal) {\n return {\n evidence: `window.${signature.versionGlobal}`,\n version: versionFromGlobal,\n };\n }\n\n for (const domMarker of signature.domMarkers ?? []) {\n if (input.domMarkers?.includes(domMarker)) {\n return { evidence: `${domMarker} DOM marker` };\n }\n }\n\n for (const storageKey of signature.storageKeys ?? []) {\n if (lowerCaseStorageKeys.has(storageKey.toLowerCase())) {\n return { evidence: `\"${storageKey}\" cookie / storage key` };\n }\n }\n\n for (const pattern of signature.html ?? []) {\n const match = input.html?.match(pattern);\n if (match) {\n return {\n evidence: `HTML contains \"${truncate(match[0])}\"`,\n version: match[1],\n };\n }\n }\n\n const isThirdPartyService =\n signature.category === 'tms' || signature.category === 'translation-proxy';\n const pageHostname = input.pageUrl\n ? parseUrl(input.pageUrl)?.hostname\n : undefined;\n const isOwnSite = (url: string): boolean => {\n const hostname = parseUrl(url)?.hostname;\n return (\n Boolean(pageHostname && hostname) &&\n getSiteDomain(hostname as string) ===\n getSiteDomain(pageHostname as string)\n );\n };\n\n for (const pattern of signature.resourceUrls ?? []) {\n const resourceUrl = input.resourceUrls?.find(\n (url) => pattern.test(url) && !(isThirdPartyService && isOwnSite(url))\n );\n if (resourceUrl) {\n return { evidence: `loads ${truncate(resourceUrl, 90)}` };\n }\n }\n\n for (const pattern of signature.bundle ?? []) {\n for (const script of input.scripts ?? []) {\n const match = script.slice(0, MAX_SCRIPT_LENGTH).match(pattern);\n if (match) {\n return {\n evidence: `JavaScript bundle contains \"${truncate(match[0])}\"`,\n version: match[1],\n };\n }\n }\n }\n\n return undefined;\n};\n\n/**\n * Every window global and version path referenced by the signatures. Live-page\n * collectors (extension, puppeteer) read these and pass back the ones present.\n */\nexport const getTechnologyGlobalNames = (): {\n globals: string[];\n versionGlobals: string[];\n} => ({\n globals: [\n ...new Set(\n technologySignatures.flatMap((signature) => signature.globals ?? [])\n ),\n ],\n versionGlobals: technologySignatures.flatMap((signature) =>\n signature.versionGlobal ? [signature.versionGlobal] : []\n ),\n});\n\n/**\n * Detect the frameworks, i18n libraries, TMS and translation proxies used by a\n * page from the raw signals collected on it.\n *\n * @param input - The {@link TechnologyDetectionInput} signals.\n * @param signatures - Fingerprints to test, defaults to {@link technologySignatures}.\n * @returns Detected technologies, in signature order.\n */\nexport const detectTechnologies = (\n input: TechnologyDetectionInput,\n signatures: readonly TechnologySignature[] = technologySignatures\n): DetectedTechnology[] => {\n const lowerCaseStorageKeys = new Set(\n (input.storageKeys ?? []).map((key) => key.toLowerCase())\n );\n\n const matchedTechnologies: DetectedTechnology[] = [];\n for (const signature of signatures) {\n const match = matchSignature(signature, input, lowerCaseStorageKeys);\n if (!match) continue;\n matchedTechnologies.push({\n id: signature.id,\n name: signature.name,\n category: signature.category,\n version: match.version || undefined,\n evidence: match.evidence,\n });\n }\n\n const signatureById = new Map(\n signatures.map((signature) => [signature.id, signature])\n );\n const getIds = (technologies: DetectedTechnology[]): Set<string> =>\n new Set(technologies.map(({ id }) => id));\n\n // Requirements first, so a dropped technology cannot hide another one.\n const matchedIds = getIds(matchedTechnologies);\n const supportedTechnologies = matchedTechnologies.filter(\n ({ id }) =>\n !signatureById\n .get(id)\n ?.requires?.some((requiredId) => !matchedIds.has(requiredId))\n );\n\n const supportedIds = getIds(supportedTechnologies);\n return supportedTechnologies.filter(\n ({ id }) =>\n !signatureById\n .get(id)\n ?.hiddenBy?.some((hidingId) => supportedIds.has(hidingId))\n );\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { technologySignatures } from "./technologySignatures.mjs";
|
|
2
|
+
import { getFirstPathSegment, getSiteDomain, isAbsoluteUrl, normalizeHostname, normalizeUrl, parseUrl } from "./url.mjs";
|
|
3
|
+
import { detectTechnologies, getTechnologyGlobalNames } from "./detectTechnologies.mjs";
|
|
4
|
+
import { findMatchingLocale, getLanguageCode, isRightToLeftLocale, isSameLanguage, isSameLocale, isValidLocaleCode, isValidOpenGraphLocale, looksLikeLocaleCode, normalizeLocaleCode, toOpenGraphLocale } from "./localeCode.mjs";
|
|
5
|
+
import { detectRoutingStrategy, getUrlLocale } from "./detectRoutingStrategy.mjs";
|
|
6
|
+
import { classifyInternalLinks } from "./classifyInternalLinks.mjs";
|
|
7
|
+
import { getCheckDetailLines, splitCheckDetails } from "./checkDetails.mjs";
|
|
8
|
+
import { getLocalizedPages, isBaseLocalePage } from "./localizedPages.mjs";
|
|
9
|
+
|
|
10
|
+
export { classifyInternalLinks, detectRoutingStrategy, detectTechnologies, findMatchingLocale, getCheckDetailLines, getFirstPathSegment, getLanguageCode, getLocalizedPages, getSiteDomain, getTechnologyGlobalNames, getUrlLocale, isAbsoluteUrl, isBaseLocalePage, isRightToLeftLocale, isSameLanguage, isSameLocale, isValidLocaleCode, isValidOpenGraphLocale, looksLikeLocaleCode, normalizeHostname, normalizeLocaleCode, normalizeUrl, parseUrl, splitCheckDetails, technologySignatures, toOpenGraphLocale };
|