webperf-snippets 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,12 @@ const SNIPPET_ALIASES = {
11
11
  CLS: "CoreWebVitals/CLS",
12
12
  INP: "CoreWebVitals/INP",
13
13
  "LCP-Subparts": "CoreWebVitals/LCP-Subparts",
14
+ // The full names of the metrics, in any case: the words someone types who does not know the acronym
15
+ "Largest-Contentful-Paint": "CoreWebVitals/LCP",
16
+ "Cumulative-Layout-Shift": "CoreWebVitals/CLS",
17
+ "Interaction-to-Next-Paint": "CoreWebVitals/INP",
18
+ "First-Contentful-Paint": "Loading/FCP",
19
+ "Time-to-First-Byte": "Loading/TTFB",
14
20
  fonts: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
15
21
  "Fonts-Preloaded-Loaded-and-used-above-the-fold":
16
22
  "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold",
@@ -65,34 +71,99 @@ export function listSnippets() {
65
71
  return cache;
66
72
  }
67
73
 
74
+ // The edits between two strings. Swapping two neighbours counts as one: LPC is one edit from LCP.
68
75
  function distance(a, b) {
69
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
76
+ let twoBack = [];
77
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
70
78
  for (let i = 1; i <= a.length; i++) {
71
- let previous = row[0];
72
- row[0] = i;
79
+ const row = [i];
73
80
  for (let j = 1; j <= b.length; j++) {
74
- const current = row[j];
75
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
76
- previous = current;
81
+ row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
82
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) row[j] = Math.min(row[j], twoBack[j - 2] + 1);
77
83
  }
84
+ twoBack = previous;
85
+ previous = row;
78
86
  }
79
- return row[b.length];
87
+ return previous[b.length];
80
88
  }
81
89
 
82
- // Turns an alias, a bare snippet name or a Category/Name path into a Category/Name path.
83
- // Only a listed snippet resolves, so a name can never point at a file outside the snippets.
84
- export function resolveSnippetName(name) {
90
+ // The words of a name, without their endings: LongTask and long-tasks give the same two.
91
+ function wordsOf(name) {
92
+ return name
93
+ .replace(/([a-z])([A-Z])|([A-Z])([A-Z][a-z])/g, "$1$3-$2$4")
94
+ .toLowerCase()
95
+ .split(/[^a-z0-9]+/)
96
+ .filter(Boolean)
97
+ .map((word) => (word.length > 4 ? word.replace(/(ing|ed)?s?$/, "") : word));
98
+ }
99
+
100
+ // The words of a guess that say nothing of what a snippet looks at. They rank, but do not make a match.
101
+ const GENERIC_WORDS = new Set(wordsOf("and the of with by find audit analysis analyzer info detection validation performance"));
102
+
103
+ // Equal, inside a longer word of the name (font in webfont), or a typo apart. Words of three letters only
104
+ // count when equal, and a typo needs five letters: size and side are one edit apart.
105
+ function isSameWord(guessed, word) {
106
+ if (guessed === word) return true;
107
+ if (Math.min(guessed.length, word.length) < 4) return false;
108
+ return word.includes(guessed) || (Math.max(guessed.length, word.length) > 4 && distance(guessed, word) === 1);
109
+ }
110
+
111
+ // How close a guess is to a name or an alias of a snippet, from 0 to 1: the share of its words the name
112
+ // has, or how alike they are spelled, which catches what splits into other words, such as LCP-Sub-Parts.
113
+ // Under MIN_SPELLING the likeness is chance: any two short names share letters.
114
+ const MIN_SPELLING = 0.65;
115
+ const MIN_SCORE = 0.5;
116
+ const NEAR_THE_FIRST = 0.75;
117
+ const MAX_SUGGESTIONS = 5;
118
+
119
+ function closeness(guess, name) {
120
+ const [a, b] = [guess.toLowerCase(), name.toLowerCase()];
121
+ const spelling = 1 - distance(a, b) / Math.max(a.length, b.length);
122
+ const words = wordsOf(name);
123
+ const guessed = wordsOf(guess);
124
+ const found = guessed.filter((word) => words.some((other) => isSameWord(word, other)));
125
+ const shared = found.some((word) => !GENERIC_WORDS.has(word)) ? found.length / guessed.length : 0;
126
+ return Math.max(spelling >= MIN_SPELLING ? spelling : 0, shared);
127
+ }
128
+
129
+ // The snippets a guess may have meant, the closest first, and on a tie the shortest name, which says the
130
+ // least else: Script-Loading before Inline-Script-Info-and-Size.
131
+ function suggestionsFor(guess) {
132
+ const ranked = listSnippets()
133
+ .map((path) => {
134
+ const name = path.split("/")[1];
135
+ const score = Math.max(...[name, ...aliasesOf(path)].map((n) => closeness(guess, n)));
136
+ return { path, score, length: wordsOf(name).length };
137
+ })
138
+ .filter((snippet) => snippet.score >= MIN_SCORE)
139
+ .sort((a, b) => b.score - a.score || a.length - b.length);
140
+ return ranked
141
+ .filter((snippet) => snippet.score >= ranked[0].score * NEAR_THE_FIRST)
142
+ .slice(0, MAX_SUGGESTIONS)
143
+ .map((snippet) => snippet.path);
144
+ }
145
+
146
+ // An alias as written, then a snippet name in any case, then an alias in any case: TTFB is the snippet,
147
+ // and ttfb the alias of its sub-parts.
148
+ function findSnippet(name) {
85
149
  if (Object.hasOwn(SNIPPET_ALIASES, name)) return SNIPPET_ALIASES[name];
86
- const all = listSnippets();
87
150
  const wanted = name.toLowerCase();
88
- const found = all.find((path) => path.toLowerCase() === wanted || path.split("/")[1].toLowerCase() === wanted);
151
+ const snippet = listSnippets().find((path) => path.toLowerCase() === wanted || path.split("/")[1].toLowerCase() === wanted);
152
+ if (snippet) return snippet;
153
+ const alias = Object.keys(SNIPPET_ALIASES).find((key) => key.toLowerCase() === wanted);
154
+ return alias && SNIPPET_ALIASES[alias];
155
+ }
156
+
157
+ // Turns an alias, a bare snippet name or a Category/Name path into a Category/Name path. The name decides
158
+ // and not the category: no two snippets share a name, so a wrong category can only mean that snippet.
159
+ // Only a listed snippet resolves, so a name can never point at a file outside the snippets.
160
+ export function resolveSnippetName(name) {
161
+ const [category, bare, ...rest] = name.split("/");
162
+ const inCategory = bare && rest.length === 0 && CATEGORIES.some((c) => c.toLowerCase() === category.toLowerCase());
163
+ const found = findSnippet(name) ?? (inCategory ? findSnippet(bare) : undefined);
89
164
  if (found) return found;
90
165
 
91
- const bare = wanted.split("/").pop();
92
- const closest = all
93
- .map((path) => ({ path, score: distance(bare, path.split("/")[1].toLowerCase()) }))
94
- .sort((a, b) => a.score - b.score)
95
- .slice(0, 3)
96
- .map((c) => c.path);
97
- throw new Error(`Unknown snippet "${name}". Closest: ${closest.join(", ")}. Run with a snippet name such as Compression-Audit, or a Category/Name path.`);
166
+ const suggestions = suggestionsFor(name.split("/").pop());
167
+ const answer = suggestions.length > 0 ? `Closest: ${suggestions.join(", ")}.` : "No snippet has a close name.";
168
+ throw new Error(`Unknown snippet "${name}". ${answer} Every name is in the list_snippets tool of the MCP server and in https://webperf-snippets.net/llms.txt.`);
98
169
  }