{"version":3,"file":"calculateScore.cjs","names":[],"sources":["../../../src/scan/calculateScore.ts"],"sourcesContent":["/**\n * Mutualized SEO/i18n scoring logic.\n *\n * This module is the single source of truth for turning audit/scan events into\n * a weighted score. It is consumed both by the hosted backend SEO audit\n * (`apps/backend`) and by the `intlayer scan` CLI command, so it must stay free\n * of any server-only dependency (no Fastify, no Cheerio, no logger coupling).\n */\n\n/** Accumulated score across all checks that ran. */\nexport type Score = {\n  /** Sum of points obtained (success = full, warning = half, error = none). */\n  score: number;\n  /** Maximum achievable points for the checks that ran. */\n  totalScore: number;\n};\n\n/**\n * Minimal shape of an event needed to contribute to the score.\n *\n * The `type` is the check identifier. URL-scoped checks are suffixed with the\n * URL using a backslash separator (e.g. `url_htmlLang\\https://example.com`), so\n * only the part before the first backslash is used to look up the weight.\n */\nexport type ScorableEvent = {\n  type?: string;\n  status?: string;\n};\n\n/**\n * Resolve how many points an event contributes based on its status.\n * - `warning` → half of the weight\n * - `error` → none\n * - anything else (`success`, `started`, `done`, …) → full weight\n */\nconst scoreCheck = (score: number, event: ScorableEvent): number => {\n  if (event.status === 'warning') return score / 2;\n  if (event.status === 'error') return 0;\n  return score;\n};\n\n/** Weight (in points) of every scorable check. */\nexport const scoreRecord = {\n  robots_robotsPresent: 10,\n  robots_noLocalizedUrlsForgotten: 8,\n  sitemap_sitemapPresent: 10,\n  sitemap_noLocalizedUrlsForgotten: 9,\n  sitemap_hasAlternates: 8,\n  sitemap_hasXDefault: 7,\n  url_htmlLang: 9,\n  url_htmlDir: 3,\n  url_hasCanonical: 10,\n  url_hreflang: 9,\n  url_hasLocalizedLinks: 8,\n  url_hasXDefault: 7,\n  url_allAnchorsLocalized: 6,\n  url_currentLocale: 3,\n  url_unusedBundleContent: 8,\n} as const;\n\n/** Identifier of a scorable check (without the URL suffix). */\nexport type ScoreCheckType = keyof typeof scoreRecord;\n\n/**\n * Apply a single event to the running score, returning a new {@link Score}.\n * Unknown check types are ignored so the function is safe to call on every\n * emitted event.\n *\n * @param score - The current accumulated score.\n * @param event - The event to fold into the score.\n * @returns A new score with the event applied.\n */\nexport const mutateScore = (score: Score, event: ScorableEvent): Score => {\n  const newScore: Score = { ...score };\n\n  const typeWithoutUrl = event.type?.split('\\\\')[0];\n\n  if (!typeWithoutUrl) {\n    return newScore;\n  }\n\n  const scoreValue = scoreRecord[typeWithoutUrl as ScoreCheckType];\n\n  if (typeof scoreValue === 'number') {\n    newScore.score += scoreCheck(scoreValue, event);\n    newScore.totalScore += scoreValue;\n  }\n\n  return newScore;\n};\n\n/**\n * Convert a raw {@link Score} into a 0–100 percentage.\n *\n * @param score - The accumulated score.\n * @returns The rounded percentage, or 0 when no check ran.\n */\nexport const toScorePercent = (score: Score): number =>\n  Math.round(score.totalScore > 0 ? (score.score / score.totalScore) * 100 : 0);\n"],"mappings":";;;;;;;;AAmCA,MAAM,cAAc,OAAe,UAAiC;CAClE,IAAI,MAAM,WAAW,WAAW,OAAO,QAAQ;CAC/C,IAAI,MAAM,WAAW,SAAS,OAAO;CACrC,OAAO;AACT;;AAGA,MAAa,cAAc;CACzB,sBAAsB;CACtB,iCAAiC;CACjC,wBAAwB;CACxB,kCAAkC;CAClC,uBAAuB;CACvB,qBAAqB;CACrB,cAAc;CACd,aAAa;CACb,kBAAkB;CAClB,cAAc;CACd,uBAAuB;CACvB,iBAAiB;CACjB,yBAAyB;CACzB,mBAAmB;CACnB,yBAAyB;AAC3B;;;;;;;;;;AAcA,MAAa,eAAe,OAAc,UAAgC;CACxE,MAAM,WAAkB,EAAE,GAAG,MAAM;CAEnC,MAAM,iBAAiB,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC;CAE/C,IAAI,CAAC,gBACH,OAAO;CAGT,MAAM,aAAa,YAAY;CAE/B,IAAI,OAAO,eAAe,UAAU;EAClC,SAAS,SAAS,WAAW,YAAY,KAAK;EAC9C,SAAS,cAAc;CACzB;CAEA,OAAO;AACT;;;;;;;AAQA,MAAa,kBAAkB,UAC7B,KAAK,MAAM,MAAM,aAAa,IAAK,MAAM,QAAQ,MAAM,aAAc,MAAM,CAAC"}