@nurkamol/seo-audit 1.31.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.
@@ -0,0 +1,71 @@
1
+ // Comparing one run against another.
2
+ //
3
+ // "How many warnings does this site have" is rarely the useful question, and
4
+ // after the first pass the answer stops changing. "Did this deploy break
5
+ // something that worked yesterday" is the question worth failing a build over.
6
+ import { causePayload } from './causes.mjs';
7
+
8
+ /** A finding's identity across runs: the check, and where it happened. */
9
+ const key = (f) => `${f.id}\u0000${f.url ?? ''}`;
10
+
11
+ export function diff(previous, current) {
12
+ const before = new Map((previous.findings ?? []).map((f) => [key(f), f]));
13
+ const after = new Map(current.map((f) => [key(f), f]));
14
+
15
+ const added = current.filter((f) => !before.has(key(f)));
16
+ const fixed = [...before.values()].filter((f) => !after.has(key(f)));
17
+ const unchanged = current.length - added.length;
18
+
19
+ return { added, fixed, unchanged, previousDate: previous.meta?.date ?? 'the baseline' };
20
+ }
21
+
22
+ /** The run as JSON.
23
+ *
24
+ * Two callers with different needs. A **baseline** is committed and diffed in
25
+ * git, so it carries the five fields a finding is identified by and nothing
26
+ * that moves on its own: how many links point at a page changes every time the
27
+ * site does, and a baseline whose git diff churns is a baseline nobody reads.
28
+ * A **report** carries everything the HTML shows, because something has to be
29
+ * able to rebuild it.
30
+ *
31
+ * The shape is versioned either way, so a field added later can never make an
32
+ * old baseline silently mis-compare. */
33
+ export function serialize(findings, meta, { full = false } = {}) {
34
+ return JSON.stringify(
35
+ {
36
+ version: 1,
37
+ meta,
38
+ findings: findings.map(({ level, id, title, detail, url, indexable, reach, traffic }) => ({
39
+ level,
40
+ id,
41
+ title,
42
+ detail,
43
+ url,
44
+ ...(full && indexable === false ? { indexable } : {}),
45
+ ...(full && reach ? { reach } : {}),
46
+ ...(full && traffic ? { traffic } : {}),
47
+ })),
48
+ // A report opens with the work, not the findings, everywhere it is read —
49
+ // so a report that is read by a machine has to carry it too. Deliberately
50
+ // absent from a baseline: grouping is derived from the findings a
51
+ // baseline already holds, and it moves whenever page counts do, which is
52
+ // the churn the baseline shape exists to avoid.
53
+ ...(full ? { causes: causePayload(findings, meta.pages ?? 0) } : {}),
54
+ },
55
+ null,
56
+ 2,
57
+ );
58
+ }
59
+
60
+ export function parse(text, path) {
61
+ let data;
62
+ try {
63
+ data = JSON.parse(text);
64
+ } catch (err) {
65
+ throw new Error(`Baseline is not valid JSON (${path}): ${err.message}`);
66
+ }
67
+ if (!Array.isArray(data.findings)) {
68
+ throw new Error(`Baseline has no findings array (${path}) — is it a --json report?`);
69
+ }
70
+ return data;
71
+ }
package/src/causes.mjs ADDED
@@ -0,0 +1,167 @@
1
+ // Findings grouped by the thing that has to change, rather than by the check
2
+ // that noticed it.
3
+ //
4
+ // A real store produced 2,081 findings across 347 URLs, and 1,685 of them were
5
+ // under /products/. Those are not 1,685 problems: they are one Shopify product
6
+ // template, repeated 194 times. Reporting them per check makes the reader
7
+ // derive that themselves, page after page, and most readers stop instead.
8
+ //
9
+ // The rule is deliberately one sentence long: **the same check, on pages of the
10
+ // same section, is one piece of work.** A section is the path a page's template
11
+ // lives under — /products/, /blogs/the-library/, / — because that is how a
12
+ // generated site is actually built, one template per shape of URL. Nothing here
13
+ // guesses at severity or invents a score; it groups, counts, and orders.
14
+
15
+ import { categoryOf } from './areas.mjs';
16
+
17
+ /** The template a URL belongs to: everything up to its last segment.
18
+ *
19
+ * /products/blue-sage → /products/
20
+ * /blogs/the-library/a-post → /blogs/the-library/
21
+ * /about/ → /
22
+ * https://x.test/ → /
23
+ *
24
+ * A trailing slash is not a segment, so /about/ and /about are the same page
25
+ * in the same place, which is the one thing this must not get wrong. */
26
+ export function sectionOf(url) {
27
+ let path;
28
+ try {
29
+ path = new URL(url).pathname;
30
+ } catch {
31
+ return '/';
32
+ }
33
+ const segments = path.split('/').filter(Boolean);
34
+ if (segments.length <= 1) return '/';
35
+ // The last segment names the page; the ones before it name the template — but
36
+ // only the first two of them. Past that a path is usually a date or a
37
+ // taxonomy rather than a different template: jekyllrb.com's /news/2024/01/
38
+ // would otherwise be its own section, one per month, and 1,206 findings
39
+ // arrived as 602 "things to change" instead of a number anybody can act on.
40
+ // Capping at two took its sections from 112 to 27 and left a Shopify store's
41
+ // eight exactly as they were.
42
+ return `/${segments.slice(0, Math.min(segments.length - 1, 2)).join('/')}/`;
43
+ }
44
+
45
+ const WORST_FIRST = { error: 0, warn: 1, info: 2 };
46
+
47
+ /** Findings as pieces of work, worst and widest first.
48
+ *
49
+ * Each cause is `{ id, title, level, section, pages, findings, count }`, where
50
+ * `pages` is the distinct URLs affected and `count` is how many findings the
51
+ * cause accounts for — the two are different when one page trips a check
52
+ * several times.
53
+ *
54
+ * Ordered by level, then by how many pages carry it, then by id so two runs of
55
+ * an unchanged site produce the same report and --baseline stays meaningful. */
56
+ export function byCause(findings) {
57
+ const causes = new Map();
58
+ for (const finding of findings ?? []) {
59
+ const section = sectionOf(finding.url);
60
+ const key = `${finding.id} ${section}`;
61
+ const cause = causes.get(key) ?? {
62
+ id: finding.id,
63
+ title: finding.title,
64
+ level: finding.level,
65
+ section,
66
+ findings: [],
67
+ };
68
+ cause.findings.push(finding);
69
+ // A cause is as serious as the worst thing in it.
70
+ if (WORST_FIRST[finding.level] < WORST_FIRST[cause.level]) {
71
+ cause.level = finding.level;
72
+ cause.title = finding.title;
73
+ }
74
+ causes.set(key, cause);
75
+ }
76
+
77
+ return [...causes.values()]
78
+ .map((cause) => {
79
+ const pages = [...new Set(cause.findings.map((f) => f.url).filter(Boolean))];
80
+ // How much of the site points at the pages this cause is on, and how
81
+ // close the nearest of them is to the homepage. Both are counts of links
82
+ // that were actually read — nothing is weighted or scored.
83
+ const measured = cause.findings.filter((finding) => finding.reach);
84
+ const seen = new Set();
85
+ let inlinks = 0;
86
+ for (const finding of measured) {
87
+ if (seen.has(finding.url)) continue;
88
+ seen.add(finding.url);
89
+ inlinks += finding.reach.inlinks;
90
+ }
91
+ const depths = measured.map((finding) => finding.reach.depth).filter((d) => d !== null);
92
+ // Impressions are not a proxy for anything: where Search Console has been
93
+ // asked, this is what these pages actually do in Google.
94
+ const shown = new Set();
95
+ let impressions = 0;
96
+ for (const finding of cause.findings) {
97
+ if (!finding.traffic || shown.has(finding.url)) continue;
98
+ shown.add(finding.url);
99
+ impressions += finding.traffic.impressions;
100
+ }
101
+ return {
102
+ ...cause,
103
+ pages,
104
+ count: cause.findings.length,
105
+ inlinks: measured.length ? inlinks : null,
106
+ depth: depths.length ? Math.min(...depths) : null,
107
+ impressions: shown.size ? impressions : null,
108
+ };
109
+ })
110
+ .sort(
111
+ (a, b) =>
112
+ WORST_FIRST[a.level] - WORST_FIRST[b.level] ||
113
+ // Measured traffic first where it is known, because it is the only
114
+ // number here that is not a proxy. Then reach before breadth: a
115
+ // template on twenty pages that four hundred links point at is more of
116
+ // the site than one on fifty nobody visits.
117
+ (b.impressions ?? -1) - (a.impressions ?? -1) ||
118
+ (b.inlinks ?? -1) - (a.inlinks ?? -1) ||
119
+ b.pages.length - a.pages.length ||
120
+ a.id.localeCompare(b.id) ||
121
+ a.section.localeCompare(b.section),
122
+ );
123
+ }
124
+
125
+ /** One line of English for a cause, used by every report format so they cannot
126
+ * drift: what it is, and how much of the site it is on. */
127
+ export function causeScope(cause, totalPages) {
128
+ const pages = cause.pages.length;
129
+ if (pages <= 1) return cause.section === '/' ? 'once' : `on one page under ${cause.section}`;
130
+
131
+ const where = cause.section === '/' ? 'across the site' : `under ${cause.section}`;
132
+ const share =
133
+ totalPages && pages / totalPages >= 0.5 ? `, ${Math.round((pages / totalPages) * 100)}% of the crawl` : '';
134
+ const seen = cause.impressions
135
+ ? `, ${cause.impressions.toLocaleString()} impressions in 28 days`
136
+ : '';
137
+ const reach = seen || (cause.inlinks ? `, ${cause.inlinks.toLocaleString()} links in` : '');
138
+ const near =
139
+ cause.depth === 0 ? ', starting at the homepage' : cause.depth === 1 ? ', one click from home' : '';
140
+ return `${pages} pages ${where}${share}${reach}${near}`;
141
+ }
142
+
143
+ /** The grouping, in the shape a machine reading a report wants.
144
+ *
145
+ * Every front end that emits JSON emits this — the CLI's `--json`, the
146
+ * Worker's `?format=json`, and so the Mac app that reads it. It exists as one
147
+ * function because two of them had assembled it inline and the third had not
148
+ * emitted it at all, which meant a report from the command line and a report
149
+ * from the hosted version were different documents. `scope` is rendered here
150
+ * rather than left to the caller for the same reason: it is the sentence the
151
+ * terminal, the HTML and the app all print, and a second phrasing of it in
152
+ * another language is exactly the drift this project refuses everywhere else. */
153
+ export function causePayload(findings, totalPages) {
154
+ return byCause(findings).map((cause) => ({
155
+ id: cause.id,
156
+ title: cause.title,
157
+ level: cause.level,
158
+ section: cause.section,
159
+ count: cause.count,
160
+ pages: cause.pages,
161
+ scope: causeScope(cause, totalPages),
162
+ // Which part of the site fixes this. The HTML report groups by it, so a
163
+ // client drawing its own report groups by the same thing rather than
164
+ // carrying a second copy of that table in another language.
165
+ area: categoryOf(cause.id),
166
+ }));
167
+ }