All files / packages/design-system-2-site/src/lib/zeroheight similarity.js

100% Statements 35/35
100% Branches 22/22
100% Functions 5/5
100% Lines 31/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84                                    78x     194x 194x 194x 194x 200x 200x 7x 7x 6x 1x 1x   5x 5x   194x                       39x 39x 39x           209x                     18x 18x 18x   17x 117x 17x 17x 82x 82x 76x 76x     17x    
/**
 * Rough prose similarity between Zeroheight markdown and a ported local file.
 *
 * Only used on the first audit run, when there's no stored hash to compare:
 * the port rewrote syntax (directives, links, image paths), so the score
 * compares words and ignores markup.
 */
 
/**
 * Replace each `open ... close` span that stays on one line with a space.
 * Same result as `/\{[^}\n]*\}/g` (or `/<[^>\n]+>/g` with `allowEmpty: false`),
 * but a linear string scan: those regexes get flagged for backtracking (Sonar S5852).
 * @param {string} text
 * @param {string} open
 * @param {string} close
 * @param {{ allowEmpty?: boolean }} [options]
 */
function stripSpans(text, open, close, { allowEmpty = true } = {}) {
  return text
    .split('\n')
    .map((line) => {
      let out = '';
      let from = 0;
      let search = 0;
      for (;;) {
        const start = line.indexOf(open, search);
        if (start === -1) break;
        const end = line.indexOf(close, start + 1);
        if (end === -1) break;
        if (end === start + 1 && !allowEmpty) {
          search = end;
          continue;
        }
        out += `${line.slice(from, start)} `;
        from = search = end + 1;
      }
      return out + line.slice(from);
    })
    .join('\n');
}
 
/**
 * Lowercase word tokens with frontmatter, URLs, tags, and directive
 * attributes removed.
 * @param {string} text
 * @returns {string[]}
 */
export function tokenize(text) {
  const withoutFrontmatter = text.replace(/^---\n[\s\S]*?\n---\n?/, '');
  const withoutMarkup = stripSpans(stripSpans(withoutFrontmatter, '{', '}'), '<', '>', { allowEmpty: false });
  return (
    withoutMarkup
      .replace(/\]\([^)\s]*\)/g, ']')
      .replace(/(?:https?|zeroheight):\/\/\S+/g, ' ')
      .toLowerCase()
      .match(/[a-z0-9]+(?:'[a-z]+)?/g)
      ?.filter((w) => w.length > 1) ?? []
  );
}
 
/**
 * Dice coefficient over word multisets: 1 = same words, 0 = nothing shared.
 * Two empty texts score 1.
 * @param {string} a
 * @param {string} b
 */
export function similarity(a, b) {
  const ta = tokenize(a);
  const tb = tokenize(b);
  if (!ta.length && !tb.length) return 1;
  /** @type {Map<string, number>} */
  const counts = new Map();
  for (const w of ta) counts.set(w, (counts.get(w) ?? 0) + 1);
  let shared = 0;
  for (const w of tb) {
    const n = counts.get(w) ?? 0;
    if (n > 0) {
      shared++;
      counts.set(w, n - 1);
    }
  }
  return (2 * shared) / (ta.length + tb.length);
}