framewatch-mcp-server 0.1.0 → 0.2.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.
Files changed (133) hide show
  1. package/README.md +895 -15
  2. package/dist/constants.d.ts +274 -0
  3. package/dist/constants.js +279 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.d.ts +20 -4
  6. package/dist/engine/browser.js +26 -9
  7. package/dist/engine/browser.js.map +1 -1
  8. package/dist/engine/clicks.d.ts +221 -0
  9. package/dist/engine/clicks.js +801 -0
  10. package/dist/engine/clicks.js.map +1 -0
  11. package/dist/engine/forms.d.ts +137 -0
  12. package/dist/engine/forms.js +474 -0
  13. package/dist/engine/forms.js.map +1 -0
  14. package/dist/engine/hmr.d.ts +41 -0
  15. package/dist/engine/hmr.js +91 -0
  16. package/dist/engine/hmr.js.map +1 -0
  17. package/dist/engine/inspect.d.ts +31 -0
  18. package/dist/engine/inspect.js +383 -0
  19. package/dist/engine/inspect.js.map +1 -0
  20. package/dist/engine/interaction.d.ts +12 -7
  21. package/dist/engine/interaction.js +110 -18
  22. package/dist/engine/interaction.js.map +1 -1
  23. package/dist/engine/links.d.ts +134 -0
  24. package/dist/engine/links.js +384 -0
  25. package/dist/engine/links.js.map +1 -0
  26. package/dist/engine/mocks.d.ts +53 -0
  27. package/dist/engine/mocks.js +148 -0
  28. package/dist/engine/mocks.js.map +1 -0
  29. package/dist/engine/rtl.d.ts +129 -0
  30. package/dist/engine/rtl.js +540 -0
  31. package/dist/engine/rtl.js.map +1 -0
  32. package/dist/engine/seo.d.ts +189 -0
  33. package/dist/engine/seo.js +398 -0
  34. package/dist/engine/seo.js.map +1 -0
  35. package/dist/engine/snapshot.d.ts +29 -0
  36. package/dist/engine/snapshot.js +10 -0
  37. package/dist/engine/snapshot.js.map +1 -0
  38. package/dist/engine/vue.d.ts +54 -0
  39. package/dist/engine/vue.js +419 -0
  40. package/dist/engine/vue.js.map +1 -0
  41. package/dist/index.js +45 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/tools/accessibility.d.ts +4 -0
  44. package/dist/tools/accessibility.js +9 -2
  45. package/dist/tools/accessibility.js.map +1 -1
  46. package/dist/tools/api-mock.d.ts +405 -0
  47. package/dist/tools/api-mock.js +186 -0
  48. package/dist/tools/api-mock.js.map +1 -0
  49. package/dist/tools/capture.d.ts +90 -26
  50. package/dist/tools/capture.js +109 -58
  51. package/dist/tools/capture.js.map +1 -1
  52. package/dist/tools/compare.d.ts +4 -0
  53. package/dist/tools/compare.js +16 -5
  54. package/dist/tools/compare.js.map +1 -1
  55. package/dist/tools/dead-clicks.d.ts +128 -0
  56. package/dist/tools/dead-clicks.js +570 -0
  57. package/dist/tools/dead-clicks.js.map +1 -0
  58. package/dist/tools/form-test.d.ts +112 -0
  59. package/dist/tools/form-test.js +477 -0
  60. package/dist/tools/form-test.js.map +1 -0
  61. package/dist/tools/index.d.ts +17 -1
  62. package/dist/tools/index.js +45 -1
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/inspect.d.ts +78 -0
  65. package/dist/tools/inspect.js +136 -0
  66. package/dist/tools/inspect.js.map +1 -0
  67. package/dist/tools/interact.d.ts +37 -18
  68. package/dist/tools/interact.js +113 -13
  69. package/dist/tools/interact.js.map +1 -1
  70. package/dist/tools/links.d.ts +129 -0
  71. package/dist/tools/links.js +640 -0
  72. package/dist/tools/links.js.map +1 -0
  73. package/dist/tools/responsive.d.ts +10 -6
  74. package/dist/tools/responsive.js +21 -4
  75. package/dist/tools/responsive.js.map +1 -1
  76. package/dist/tools/rtl.d.ts +241 -0
  77. package/dist/tools/rtl.js +410 -0
  78. package/dist/tools/rtl.js.map +1 -0
  79. package/dist/tools/save-auth.d.ts +263 -0
  80. package/dist/tools/save-auth.js +253 -0
  81. package/dist/tools/save-auth.js.map +1 -0
  82. package/dist/tools/screenshot.d.ts +4 -0
  83. package/dist/tools/screenshot.js +15 -4
  84. package/dist/tools/screenshot.js.map +1 -1
  85. package/dist/tools/seo.d.ts +113 -0
  86. package/dist/tools/seo.js +281 -0
  87. package/dist/tools/seo.js.map +1 -0
  88. package/dist/tools/snapshot.d.ts +122 -0
  89. package/dist/tools/snapshot.js +183 -0
  90. package/dist/tools/snapshot.js.map +1 -0
  91. package/dist/tools/wait-for.d.ts +107 -0
  92. package/dist/tools/wait-for.js +167 -0
  93. package/dist/tools/wait-for.js.map +1 -0
  94. package/dist/utils/arabic-text.d.ts +14 -0
  95. package/dist/utils/arabic-text.js +193 -0
  96. package/dist/utils/arabic-text.js.map +1 -0
  97. package/dist/utils/budget.d.ts +41 -0
  98. package/dist/utils/budget.js +182 -0
  99. package/dist/utils/budget.js.map +1 -0
  100. package/dist/utils/format.d.ts +11 -1
  101. package/dist/utils/format.js +27 -4
  102. package/dist/utils/format.js.map +1 -1
  103. package/dist/utils/highlight.d.ts +69 -0
  104. package/dist/utils/highlight.js +181 -0
  105. package/dist/utils/highlight.js.map +1 -0
  106. package/dist/utils/link-rules.d.ts +100 -0
  107. package/dist/utils/link-rules.js +284 -0
  108. package/dist/utils/link-rules.js.map +1 -0
  109. package/dist/utils/mock-rules.d.ts +144 -0
  110. package/dist/utils/mock-rules.js +224 -0
  111. package/dist/utils/mock-rules.js.map +1 -0
  112. package/dist/utils/rtl-rules.d.ts +142 -0
  113. package/dist/utils/rtl-rules.js +296 -0
  114. package/dist/utils/rtl-rules.js.map +1 -0
  115. package/dist/utils/seo-rules.d.ts +129 -0
  116. package/dist/utils/seo-rules.js +726 -0
  117. package/dist/utils/seo-rules.js.map +1 -0
  118. package/dist/utils/snapshot-rules.d.ts +33 -0
  119. package/dist/utils/snapshot-rules.js +111 -0
  120. package/dist/utils/snapshot-rules.js.map +1 -0
  121. package/dist/utils/storage-state.d.ts +76 -0
  122. package/dist/utils/storage-state.js +195 -0
  123. package/dist/utils/storage-state.js.map +1 -0
  124. package/dist/utils/style-rules.d.ts +107 -0
  125. package/dist/utils/style-rules.js +223 -0
  126. package/dist/utils/style-rules.js.map +1 -0
  127. package/dist/utils/test-data.d.ts +75 -0
  128. package/dist/utils/test-data.js +294 -0
  129. package/dist/utils/test-data.js.map +1 -0
  130. package/dist/utils/vue-rules.d.ts +72 -0
  131. package/dist/utils/vue-rules.js +108 -0
  132. package/dist/utils/vue-rules.js.map +1 -0
  133. package/package.json +6 -4
@@ -0,0 +1,296 @@
1
+ import { MAX_RTL_TEXT_LENGTH, RTL_ALIGN_TOLERANCE_PX, RTL_MIRROR_TOLERANCE_PX, RTL_OVERFLOW_TOLERANCE_PX, } from "../constants.js";
2
+ /**
3
+ * Judge one element, having seen it in both directions.
4
+ *
5
+ * Returns every issue it has; an element with none is not a finding and is
6
+ * never mentioned. The checks are deliberately independent — an element can
7
+ * both fail to mirror and newly overflow, and those are two different bugs
8
+ * with two different fixes.
9
+ */
10
+ export function judgeElement(pair) {
11
+ const issues = [];
12
+ const { ltr, rtl } = pair;
13
+ const mirror = judgeMirror(ltr, rtl);
14
+ if (mirror)
15
+ issues.push(mirror);
16
+ const align = judgeAlignment(ltr, rtl);
17
+ if (align)
18
+ issues.push(align);
19
+ const flex = judgeFlex(ltr, rtl);
20
+ if (flex)
21
+ issues.push(flex);
22
+ const padding = judgePadding(ltr, rtl);
23
+ if (padding)
24
+ issues.push(padding);
25
+ const overflow = judgeOverflow(ltr, rtl);
26
+ if (overflow)
27
+ issues.push(overflow);
28
+ const icon = judgeIcon(ltr, rtl);
29
+ if (icon)
30
+ issues.push(icon);
31
+ return issues;
32
+ }
33
+ /**
34
+ * Did the box move to where its mirror image should be?
35
+ *
36
+ * An element at x in an LTR viewport of width W belongs at
37
+ * `W - x - width` in RTL. Anything that is off-centre in LTR and has not moved
38
+ * in RTL never mirrored — the single most common RTL bug there is, and the one
39
+ * that a screenshot makes obvious only once somebody knows to look.
40
+ *
41
+ * Two things are deliberately *not* findings. An element that is already
42
+ * centred (or full-width) has a mirror position equal to its own, so it can
43
+ * never fail this check — which is right: there is nothing to mirror. And an
44
+ * element that moved somewhere other than its exact mirror is left alone,
45
+ * because a page may legitimately reflow in RTL; only a box that did not move
46
+ * *at all* is evidence of a forgotten direction.
47
+ */
48
+ function judgeMirror(ltr, rtl) {
49
+ const expected = mirroredX(ltr);
50
+ const offCentre = Math.abs(expected - ltr.x);
51
+ // Symmetric in LTR: mirroring is a no-op, so staying put proves nothing.
52
+ if (offCentre <= RTL_MIRROR_TOLERANCE_PX)
53
+ return null;
54
+ const moved = Math.abs(rtl.x - ltr.x);
55
+ if (moved > RTL_MIRROR_TOLERANCE_PX)
56
+ return null;
57
+ return {
58
+ kind: "not_mirrored",
59
+ severity: "problem",
60
+ message: "did not mirror — the box sits at the same place in both directions",
61
+ evidence: `x=${round(ltr.x)} in LTR, x=${round(rtl.x)} in RTL; ` +
62
+ `mirroring a ${round(ltr.width)}px box in a ${round(ltr.viewport_width)}px viewport should put it at ` +
63
+ `x=${round(expected)}`,
64
+ };
65
+ }
66
+ /** Where a box's mirror image starts, in the same coordinate space. */
67
+ export function mirroredX(m) {
68
+ return m.viewport_width - m.x - m.width;
69
+ }
70
+ /**
71
+ * Is the text still aligned to the physical left?
72
+ *
73
+ * Only a finding when the element has text of its own *and* the alignment did
74
+ * not change between the two renders. `text-align: start` resolves to `left`
75
+ * in LTR and `right` in RTL, so a correctly written page shows two different
76
+ * values here and says nothing. Two identical `left`s mean the author wrote
77
+ * `left` rather than `start`, and the Arabic will hug the wrong edge.
78
+ *
79
+ * `center` and `justify` are the same in both directions by definition and are
80
+ * never reported.
81
+ */
82
+ function judgeAlignment(ltr, rtl) {
83
+ if (ltr.text === "")
84
+ return null;
85
+ const align = normaliseAlign(rtl.text_align);
86
+ if (align !== "left")
87
+ return null;
88
+ if (normaliseAlign(ltr.text_align) !== "left")
89
+ return null;
90
+ return {
91
+ kind: "alignment",
92
+ severity: "problem",
93
+ message: "stayed left-aligned in an RTL context — the text hugs the wrong edge",
94
+ evidence: `text-align is "${rtl.text_align}" in both directions; use \`start\` (or \`end\`) instead of \`left\``,
95
+ };
96
+ }
97
+ /** `start`/`end` are resolved by the browser; everything else is compared as written. */
98
+ function normaliseAlign(value) {
99
+ return String(value ?? "").trim().toLowerCase();
100
+ }
101
+ /**
102
+ * Did a row of flex items reverse?
103
+ *
104
+ * A `row` flex container follows `direction`, so its items reverse on their
105
+ * own and the computed value stays `row` in both renders — meaning the
106
+ * computed property tells us nothing. What tells us something is whether the
107
+ * *children* actually swapped, which the caller measures and hands over as the
108
+ * child order. Here we only catch the explicit case: a container hard-coded to
109
+ * `row-reverse` in both directions is reversed twice and ends up
110
+ * back-to-front in RTL.
111
+ */
112
+ function judgeFlex(ltr, rtl) {
113
+ const rtlDir = String(rtl.flex_direction ?? "").trim().toLowerCase();
114
+ if (rtlDir !== "row-reverse")
115
+ return null;
116
+ if (String(ltr.flex_direction ?? "").trim().toLowerCase() !== "row-reverse")
117
+ return null;
118
+ return {
119
+ kind: "flex_not_reversed",
120
+ severity: "warning",
121
+ message: "is `row-reverse` in both directions — RTL reverses it again, so the items end up back to front",
122
+ evidence: "flex-direction: row-reverse under `dir=rtl` lays the items out left to right",
123
+ };
124
+ }
125
+ /**
126
+ * Asymmetric physical padding that never swapped.
127
+ *
128
+ * A gap of 24px on the left and 0 on the right is a deliberate offset in LTR
129
+ * and a mistake in RTL — unless it swapped, which is what `padding-inline`
130
+ * does and what this checks. Only reported as a warning: plenty of asymmetric
131
+ * padding is decorative and correct in both directions.
132
+ */
133
+ function judgePadding(ltr, rtl) {
134
+ const gap = Math.abs(ltr.padding_left - ltr.padding_right);
135
+ if (gap <= RTL_ALIGN_TOLERANCE_PX)
136
+ return null;
137
+ // Swapped correctly — `padding-inline-start` did its job.
138
+ const swapped = Math.abs(rtl.padding_left - ltr.padding_right) <= RTL_ALIGN_TOLERANCE_PX &&
139
+ Math.abs(rtl.padding_right - ltr.padding_left) <= RTL_ALIGN_TOLERANCE_PX;
140
+ if (swapped)
141
+ return null;
142
+ // Unchanged is the finding; anything else is a reflow we do not judge.
143
+ const unchanged = Math.abs(rtl.padding_left - ltr.padding_left) <= RTL_ALIGN_TOLERANCE_PX &&
144
+ Math.abs(rtl.padding_right - ltr.padding_right) <= RTL_ALIGN_TOLERANCE_PX;
145
+ if (!unchanged)
146
+ return null;
147
+ return {
148
+ kind: "padding_not_mirrored",
149
+ severity: "warning",
150
+ message: "keeps the same physical padding in both directions",
151
+ evidence: `padding-left ${round(rtl.padding_left)}px / padding-right ${round(rtl.padding_right)}px, unchanged from LTR; ` +
152
+ "`padding-inline-start`/`padding-inline-end` would swap",
153
+ };
154
+ }
155
+ /**
156
+ * Content that fits in LTR and sticks out in RTL.
157
+ *
158
+ * This is the one check that reports a *change* rather than a failure to
159
+ * change, and it is the one that catches the bug nobody sees coming: a layout
160
+ * that is pinned with `left`/`right` offsets, or one whose text got longer,
161
+ * pushing content off the edge of the screen — where it is silently cropped
162
+ * and therefore invisible in the screenshot.
163
+ *
164
+ * Overflow that is present in LTR too is not an RTL bug; it is a layout bug,
165
+ * and `framewatch_responsive` is the tool that reports it.
166
+ */
167
+ function judgeOverflow(ltr, rtl) {
168
+ const before = Math.max(ltr.overflow_left, ltr.overflow_right);
169
+ const after = Math.max(rtl.overflow_left, rtl.overflow_right);
170
+ if (after <= RTL_OVERFLOW_TOLERANCE_PX)
171
+ return null;
172
+ if (after <= before + RTL_OVERFLOW_TOLERANCE_PX)
173
+ return null;
174
+ const edge = rtl.overflow_left > rtl.overflow_right ? "left" : "right";
175
+ const amount = Math.max(rtl.overflow_left, rtl.overflow_right);
176
+ return {
177
+ kind: "overflow",
178
+ severity: "problem",
179
+ message: `overflows the ${edge} edge of the viewport in RTL — the part that sticks out is cropped, not visible`,
180
+ evidence: `sticks out ${round(amount)}px past the ${edge} edge in RTL` +
181
+ (before > RTL_OVERFLOW_TOLERANCE_PX ? ` (it already overflowed ${round(before)}px in LTR)` : ", and none in LTR"),
182
+ };
183
+ }
184
+ /**
185
+ * An arrow or chevron that points the same way in both directions.
186
+ *
187
+ * Directional icons have to flip: a "next" chevron pointing right in Arabic
188
+ * points backwards. The caller decides what counts as directional (by name,
189
+ * by class, by aria-label); this only asks whether the thing that was supposed
190
+ * to flip actually did — via a `scaleX(-1)` transform, or by having a
191
+ * different box shape after the swap.
192
+ */
193
+ function judgeIcon(ltr, rtl) {
194
+ if (rtl.mirrorable !== true)
195
+ return null;
196
+ if (isMirroredTransform(rtl.transform) !== isMirroredTransform(ltr.transform))
197
+ return null;
198
+ return {
199
+ kind: "icon_not_mirrored",
200
+ severity: "warning",
201
+ message: "looks directional but is drawn the same way in both directions — a 'next' arrow points backwards in RTL",
202
+ evidence: rtl.transform === "none" || rtl.transform === ""
203
+ ? "no mirroring transform in either direction; `transform: scaleX(-1)` under `[dir=rtl]` flips it"
204
+ : `transform is "${rtl.transform}" in both directions`,
205
+ };
206
+ }
207
+ /**
208
+ * Does this transform flip the element horizontally?
209
+ *
210
+ * A computed transform is always a matrix, so the sign of `a` (the horizontal
211
+ * scale) is the answer. `scaleX(-1)` computes to `matrix(-1, 0, 0, 1, 0, 0)`.
212
+ */
213
+ export function isMirroredTransform(transform) {
214
+ const value = String(transform ?? "").trim();
215
+ if (value === "" || value === "none")
216
+ return false;
217
+ const match = /^matrix(?:3d)?\(\s*(-?[\d.eE+-]+)/.exec(value);
218
+ if (!match)
219
+ return /scalex\(\s*-/i.test(value);
220
+ return Number(match[1]) < 0;
221
+ }
222
+ /* ── Naming and rendering ─────────────────────────────────────────────── */
223
+ /**
224
+ * How an element is named in the report: `button "Add to cart"`, `div .card`.
225
+ *
226
+ * An icon-only control has no text at all, and a bare selector is the least
227
+ * recognisable thing to call it by — so text wins when there is any, and the
228
+ * selector is the fallback.
229
+ */
230
+ export function describeElement(m) {
231
+ const text = elide(m.text, MAX_RTL_TEXT_LENGTH);
232
+ return text === "" ? `${m.tag} ${m.selector}` : `${m.tag} "${text}"`;
233
+ }
234
+ /**
235
+ * Turn judged elements into the findings the report prints, numbered and
236
+ * ordered worst-first.
237
+ *
238
+ * Order is by severity, then by how many things are wrong with the element,
239
+ * then down the page. The numbering happens *after* that sort, so the numbers
240
+ * in the report count from the top of the list and match the labels drawn on
241
+ * the screenshot.
242
+ */
243
+ export function buildFindings(pairs) {
244
+ const judged = pairs
245
+ .map((pair) => ({ pair, issues: judgeElement(pair) }))
246
+ .filter((entry) => entry.issues.length > 0);
247
+ judged.sort((a, b) => {
248
+ const severity = rank(worst(a.issues)) - rank(worst(b.issues));
249
+ if (severity !== 0)
250
+ return severity;
251
+ if (a.issues.length !== b.issues.length)
252
+ return b.issues.length - a.issues.length;
253
+ return a.pair.rtl.y - b.pair.rtl.y || a.pair.rtl.x - b.pair.rtl.x;
254
+ });
255
+ return judged.map((entry, index) => ({
256
+ index: index + 1,
257
+ selector: entry.pair.rtl.selector,
258
+ match_index: entry.pair.rtl.match_index,
259
+ description: describeElement(entry.pair.rtl),
260
+ issues: entry.issues,
261
+ severity: worst(entry.issues),
262
+ }));
263
+ }
264
+ function worst(issues) {
265
+ return issues.some((issue) => issue.severity === "problem") ? "problem" : "warning";
266
+ }
267
+ function rank(severity) {
268
+ return severity === "problem" ? 0 : 1;
269
+ }
270
+ /** What each kind of issue is called as a section heading, and how it is explained. */
271
+ export function describeIssueKind(kind) {
272
+ switch (kind) {
273
+ case "not_mirrored":
274
+ return "did not mirror";
275
+ case "alignment":
276
+ return "text stayed left-aligned";
277
+ case "flex_not_reversed":
278
+ return "flex row reversed twice";
279
+ case "padding_not_mirrored":
280
+ return "physical padding did not swap";
281
+ case "overflow":
282
+ return "new overflow in RTL";
283
+ case "icon_not_mirrored":
284
+ return "directional icon did not flip";
285
+ case "direction_not_applied":
286
+ return "RTL was never applied";
287
+ }
288
+ }
289
+ export function elide(value, max) {
290
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
291
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;
292
+ }
293
+ function round(value) {
294
+ return Math.round(value);
295
+ }
296
+ //# sourceMappingURL=rtl-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rtl-rules.js","sourceRoot":"","sources":["../../src/utils/rtl-rules.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n MAX_RTL_TEXT_LENGTH,\n RTL_ALIGN_TOLERANCE_PX,\n RTL_MIRROR_TOLERANCE_PX,\n RTL_OVERFLOW_TOLERANCE_PX,\n} from \"../constants.js\";\n\n/**\n * What counts as an RTL bug.\n *\n * Everything here is pure: it takes one element measured in both directions\n * and returns a verdict. Nothing in this file opens a browser or touches a\n * page, which is the point — \"did this box mirror?\", \"is this alignment\n * deliberate or forgotten?\" and \"is that overflow new?\" are all judgements\n * with edge cases, and judgements need unit tests that run in milliseconds.\n *\n * The bias throughout is against false alarms, and it is stronger here than\n * anywhere else in this codebase, because static RTL analysis is notorious for\n * crying wolf. `text-align: left` is *correct* on a code block, a number\n * column and a Latin brand name; `padding-left` is correct on anything that\n * should not mirror; `flex-direction: row` is correct wherever the order is\n * physical rather than semantic. A report that flags all of those is a report\n * whose real findings are never read.\n *\n * So nothing is judged from the RTL rendering alone. Every verdict is a\n * *comparison*: the element is measured in LTR and again in RTL, and the only\n * findings are the things that failed to change when the LTR measurement\n * proves they should have. An element that is left-aligned in both directions\n * has forgotten to mirror; one that is left in LTR and right in RTL is\n * working exactly as intended, and this file says nothing about it.\n */\n\n/* ── The measurements ─────────────────────────────────────────────────── */\n\n/**\n * One element as measured in one direction.\n *\n * Deliberately flat and JSON-ish: this is what crosses the boundary out of\n * `page.evaluate`, so it can hold nothing but structured-cloneable values.\n */\nexport interface ElementMeasurement {\n /** Stable identity across the two renders — see `keyFor` in engine/rtl.ts. */\n key: string;\n /** A CSS selector for the element, for the report and the highlight overlay. */\n selector: string;\n /** Which match of `selector` this is, when the selector is not unique. */\n match_index: number;\n tag: string;\n /** Visible text, elided. Named in the report so a finding is recognisable. */\n text: string;\n\n /** Border-box in document coordinates. */\n x: number;\n y: number;\n width: number;\n height: number;\n\n /** The viewport this was measured in, so mirroring can be computed. */\n viewport_width: number;\n\n /** Computed `text-align`, already resolved from `start`/`end` by the browser. */\n text_align: string;\n /** Computed `direction` on the element itself. */\n direction: string;\n /** Computed `flex-direction`, or \"\" when the element is not a flex container. */\n flex_direction: string;\n /** Physical padding and margin, to spot asymmetry that never mirrored. */\n padding_left: number;\n padding_right: number;\n margin_left: number;\n margin_right: number;\n\n /** Overflow of this element past its own content box, and past the viewport. */\n scroll_width: number;\n client_width: number;\n /** How far the box sticks out past the right/left edge of the viewport, 0 when it fits. */\n overflow_right: number;\n overflow_left: number;\n\n /** A transform that already mirrors the element — `scaleX(-1)` on an icon. */\n transform: string;\n /** Set when this element is one an author would expect to mirror: an arrow, a chevron. */\n mirrorable?: boolean;\n}\n\n/** One element measured in both directions. */\nexport interface ElementPair {\n ltr: ElementMeasurement;\n rtl: ElementMeasurement;\n}\n\n/* ── The verdicts ─────────────────────────────────────────────────────── */\n\n/**\n * What can be wrong with an element in RTL.\n *\n * Each of these is a *failure to change*, except `overflow`, which is a change\n * that should not have happened. That asymmetry is the whole design: RTL bugs\n * are things that stayed put.\n */\nexport type RtlIssueKind =\n | \"not_mirrored\"\n | \"alignment\"\n | \"flex_not_reversed\"\n | \"padding_not_mirrored\"\n | \"overflow\"\n | \"icon_not_mirrored\"\n | \"direction_not_applied\";\n\nexport interface RtlIssue {\n kind: RtlIssueKind;\n /** How bad it is. `problem` leads the report; `warning` follows it. */\n severity: \"problem\" | \"warning\";\n /** One line naming what is wrong, in the report's voice. */\n message: string;\n /** The measured evidence, quoted so the reader can check the verdict. */\n evidence: string;\n}\n\n/** Every issue found on one element, with the element itself. */\nexport interface ElementFinding {\n /** 1-based, and the number the highlight overlay is labelled with. */\n index: number;\n selector: string;\n match_index: number;\n /** `button \"Add to cart\"` — how the element is named in the report. */\n description: string;\n issues: RtlIssue[];\n /** The worst severity among `issues`, so the report can sort and colour. */\n severity: \"problem\" | \"warning\";\n}\n\n/**\n * Judge one element, having seen it in both directions.\n *\n * Returns every issue it has; an element with none is not a finding and is\n * never mentioned. The checks are deliberately independent — an element can\n * both fail to mirror and newly overflow, and those are two different bugs\n * with two different fixes.\n */\nexport function judgeElement(pair: ElementPair): RtlIssue[] {\n const issues: RtlIssue[] = [];\n const { ltr, rtl } = pair;\n\n const mirror = judgeMirror(ltr, rtl);\n if (mirror) issues.push(mirror);\n\n const align = judgeAlignment(ltr, rtl);\n if (align) issues.push(align);\n\n const flex = judgeFlex(ltr, rtl);\n if (flex) issues.push(flex);\n\n const padding = judgePadding(ltr, rtl);\n if (padding) issues.push(padding);\n\n const overflow = judgeOverflow(ltr, rtl);\n if (overflow) issues.push(overflow);\n\n const icon = judgeIcon(ltr, rtl);\n if (icon) issues.push(icon);\n\n return issues;\n}\n\n/**\n * Did the box move to where its mirror image should be?\n *\n * An element at x in an LTR viewport of width W belongs at\n * `W - x - width` in RTL. Anything that is off-centre in LTR and has not moved\n * in RTL never mirrored — the single most common RTL bug there is, and the one\n * that a screenshot makes obvious only once somebody knows to look.\n *\n * Two things are deliberately *not* findings. An element that is already\n * centred (or full-width) has a mirror position equal to its own, so it can\n * never fail this check — which is right: there is nothing to mirror. And an\n * element that moved somewhere other than its exact mirror is left alone,\n * because a page may legitimately reflow in RTL; only a box that did not move\n * *at all* is evidence of a forgotten direction.\n */\nfunction judgeMirror(ltr: ElementMeasurement, rtl: ElementMeasurement): RtlIssue | null {\n const expected = mirroredX(ltr);\n const offCentre = Math.abs(expected - ltr.x);\n\n // Symmetric in LTR: mirroring is a no-op, so staying put proves nothing.\n if (offCentre <= RTL_MIRROR_TOLERANCE_PX) return null;\n\n const moved = Math.abs(rtl.x - ltr.x);\n if (moved > RTL_MIRROR_TOLERANCE_PX) return null;\n\n return {\n kind: \"not_mirrored\",\n severity: \"problem\",\n message: \"did not mirror — the box sits at the same place in both directions\",\n evidence:\n `x=${round(ltr.x)} in LTR, x=${round(rtl.x)} in RTL; ` +\n `mirroring a ${round(ltr.width)}px box in a ${round(ltr.viewport_width)}px viewport should put it at ` +\n `x=${round(expected)}`,\n };\n}\n\n/** Where a box's mirror image starts, in the same coordinate space. */\nexport function mirroredX(m: ElementMeasurement): number {\n return m.viewport_width - m.x - m.width;\n}\n\n/**\n * Is the text still aligned to the physical left?\n *\n * Only a finding when the element has text of its own *and* the alignment did\n * not change between the two renders. `text-align: start` resolves to `left`\n * in LTR and `right` in RTL, so a correctly written page shows two different\n * values here and says nothing. Two identical `left`s mean the author wrote\n * `left` rather than `start`, and the Arabic will hug the wrong edge.\n *\n * `center` and `justify` are the same in both directions by definition and are\n * never reported.\n */\nfunction judgeAlignment(ltr: ElementMeasurement, rtl: ElementMeasurement): RtlIssue | null {\n if (ltr.text === \"\") return null;\n\n const align = normaliseAlign(rtl.text_align);\n if (align !== \"left\") return null;\n if (normaliseAlign(ltr.text_align) !== \"left\") return null;\n\n return {\n kind: \"alignment\",\n severity: \"problem\",\n message: \"stayed left-aligned in an RTL context — the text hugs the wrong edge\",\n evidence: `text-align is \"${rtl.text_align}\" in both directions; use \\`start\\` (or \\`end\\`) instead of \\`left\\``,\n };\n}\n\n/** `start`/`end` are resolved by the browser; everything else is compared as written. */\nfunction normaliseAlign(value: string): string {\n return String(value ?? \"\").trim().toLowerCase();\n}\n\n/**\n * Did a row of flex items reverse?\n *\n * A `row` flex container follows `direction`, so its items reverse on their\n * own and the computed value stays `row` in both renders — meaning the\n * computed property tells us nothing. What tells us something is whether the\n * *children* actually swapped, which the caller measures and hands over as the\n * child order. Here we only catch the explicit case: a container hard-coded to\n * `row-reverse` in both directions is reversed twice and ends up\n * back-to-front in RTL.\n */\nfunction judgeFlex(ltr: ElementMeasurement, rtl: ElementMeasurement): RtlIssue | null {\n const rtlDir = String(rtl.flex_direction ?? \"\").trim().toLowerCase();\n if (rtlDir !== \"row-reverse\") return null;\n if (String(ltr.flex_direction ?? \"\").trim().toLowerCase() !== \"row-reverse\") return null;\n\n return {\n kind: \"flex_not_reversed\",\n severity: \"warning\",\n message: \"is `row-reverse` in both directions — RTL reverses it again, so the items end up back to front\",\n evidence: \"flex-direction: row-reverse under `dir=rtl` lays the items out left to right\",\n };\n}\n\n/**\n * Asymmetric physical padding that never swapped.\n *\n * A gap of 24px on the left and 0 on the right is a deliberate offset in LTR\n * and a mistake in RTL — unless it swapped, which is what `padding-inline`\n * does and what this checks. Only reported as a warning: plenty of asymmetric\n * padding is decorative and correct in both directions.\n */\nfunction judgePadding(ltr: ElementMeasurement, rtl: ElementMeasurement): RtlIssue | null {\n const gap = Math.abs(ltr.padding_left - ltr.padding_right);\n if (gap <= RTL_ALIGN_TOLERANCE_PX) return null;\n\n // Swapped correctly — `padding-inline-start` did its job.\n const swapped =\n Math.abs(rtl.padding_left - ltr.padding_right) <= RTL_ALIGN_TOLERANCE_PX &&\n Math.abs(rtl.padding_right - ltr.padding_left) <= RTL_ALIGN_TOLERANCE_PX;\n if (swapped) return null;\n\n // Unchanged is the finding; anything else is a reflow we do not judge.\n const unchanged =\n Math.abs(rtl.padding_left - ltr.padding_left) <= RTL_ALIGN_TOLERANCE_PX &&\n Math.abs(rtl.padding_right - ltr.padding_right) <= RTL_ALIGN_TOLERANCE_PX;\n if (!unchanged) return null;\n\n return {\n kind: \"padding_not_mirrored\",\n severity: \"warning\",\n message: \"keeps the same physical padding in both directions\",\n evidence:\n `padding-left ${round(rtl.padding_left)}px / padding-right ${round(rtl.padding_right)}px, unchanged from LTR; ` +\n \"`padding-inline-start`/`padding-inline-end` would swap\",\n };\n}\n\n/**\n * Content that fits in LTR and sticks out in RTL.\n *\n * This is the one check that reports a *change* rather than a failure to\n * change, and it is the one that catches the bug nobody sees coming: a layout\n * that is pinned with `left`/`right` offsets, or one whose text got longer,\n * pushing content off the edge of the screen — where it is silently cropped\n * and therefore invisible in the screenshot.\n *\n * Overflow that is present in LTR too is not an RTL bug; it is a layout bug,\n * and `framewatch_responsive` is the tool that reports it.\n */\nfunction judgeOverflow(ltr: ElementMeasurement, rtl: ElementMeasurement): RtlIssue | null {\n const before = Math.max(ltr.overflow_left, ltr.overflow_right);\n const after = Math.max(rtl.overflow_left, rtl.overflow_right);\n if (after <= RTL_OVERFLOW_TOLERANCE_PX) return null;\n if (after <= before + RTL_OVERFLOW_TOLERANCE_PX) return null;\n\n const edge = rtl.overflow_left > rtl.overflow_right ? \"left\" : \"right\";\n const amount = Math.max(rtl.overflow_left, rtl.overflow_right);\n return {\n kind: \"overflow\",\n severity: \"problem\",\n message: `overflows the ${edge} edge of the viewport in RTL — the part that sticks out is cropped, not visible`,\n evidence:\n `sticks out ${round(amount)}px past the ${edge} edge in RTL` +\n (before > RTL_OVERFLOW_TOLERANCE_PX ? ` (it already overflowed ${round(before)}px in LTR)` : \", and none in LTR\"),\n };\n}\n\n/**\n * An arrow or chevron that points the same way in both directions.\n *\n * Directional icons have to flip: a \"next\" chevron pointing right in Arabic\n * points backwards. The caller decides what counts as directional (by name,\n * by class, by aria-label); this only asks whether the thing that was supposed\n * to flip actually did — via a `scaleX(-1)` transform, or by having a\n * different box shape after the swap.\n */\nfunction judgeIcon(ltr: ElementMeasurement, rtl: ElementMeasurement): RtlIssue | null {\n if (rtl.mirrorable !== true) return null;\n if (isMirroredTransform(rtl.transform) !== isMirroredTransform(ltr.transform)) return null;\n\n return {\n kind: \"icon_not_mirrored\",\n severity: \"warning\",\n message: \"looks directional but is drawn the same way in both directions — a 'next' arrow points backwards in RTL\",\n evidence:\n rtl.transform === \"none\" || rtl.transform === \"\"\n ? \"no mirroring transform in either direction; `transform: scaleX(-1)` under `[dir=rtl]` flips it\"\n : `transform is \"${rtl.transform}\" in both directions`,\n };\n}\n\n/**\n * Does this transform flip the element horizontally?\n *\n * A computed transform is always a matrix, so the sign of `a` (the horizontal\n * scale) is the answer. `scaleX(-1)` computes to `matrix(-1, 0, 0, 1, 0, 0)`.\n */\nexport function isMirroredTransform(transform: string): boolean {\n const value = String(transform ?? \"\").trim();\n if (value === \"\" || value === \"none\") return false;\n const match = /^matrix(?:3d)?\\(\\s*(-?[\\d.eE+-]+)/.exec(value);\n if (!match) return /scalex\\(\\s*-/i.test(value);\n return Number(match[1]) < 0;\n}\n\n/* ── Naming and rendering ─────────────────────────────────────────────── */\n\n/**\n * How an element is named in the report: `button \"Add to cart\"`, `div .card`.\n *\n * An icon-only control has no text at all, and a bare selector is the least\n * recognisable thing to call it by — so text wins when there is any, and the\n * selector is the fallback.\n */\nexport function describeElement(m: Pick<ElementMeasurement, \"tag\" | \"text\" | \"selector\">): string {\n const text = elide(m.text, MAX_RTL_TEXT_LENGTH);\n return text === \"\" ? `${m.tag} ${m.selector}` : `${m.tag} \"${text}\"`;\n}\n\n/**\n * Turn judged elements into the findings the report prints, numbered and\n * ordered worst-first.\n *\n * Order is by severity, then by how many things are wrong with the element,\n * then down the page. The numbering happens *after* that sort, so the numbers\n * in the report count from the top of the list and match the labels drawn on\n * the screenshot.\n */\nexport function buildFindings(pairs: readonly ElementPair[]): ElementFinding[] {\n const judged = pairs\n .map((pair) => ({ pair, issues: judgeElement(pair) }))\n .filter((entry) => entry.issues.length > 0);\n\n judged.sort((a, b) => {\n const severity = rank(worst(a.issues)) - rank(worst(b.issues));\n if (severity !== 0) return severity;\n if (a.issues.length !== b.issues.length) return b.issues.length - a.issues.length;\n return a.pair.rtl.y - b.pair.rtl.y || a.pair.rtl.x - b.pair.rtl.x;\n });\n\n return judged.map((entry, index) => ({\n index: index + 1,\n selector: entry.pair.rtl.selector,\n match_index: entry.pair.rtl.match_index,\n description: describeElement(entry.pair.rtl),\n issues: entry.issues,\n severity: worst(entry.issues),\n }));\n}\n\nfunction worst(issues: readonly RtlIssue[]): \"problem\" | \"warning\" {\n return issues.some((issue) => issue.severity === \"problem\") ? \"problem\" : \"warning\";\n}\n\nfunction rank(severity: \"problem\" | \"warning\"): number {\n return severity === \"problem\" ? 0 : 1;\n}\n\n/** What each kind of issue is called as a section heading, and how it is explained. */\nexport function describeIssueKind(kind: RtlIssueKind): string {\n switch (kind) {\n case \"not_mirrored\":\n return \"did not mirror\";\n case \"alignment\":\n return \"text stayed left-aligned\";\n case \"flex_not_reversed\":\n return \"flex row reversed twice\";\n case \"padding_not_mirrored\":\n return \"physical padding did not swap\";\n case \"overflow\":\n return \"new overflow in RTL\";\n case \"icon_not_mirrored\":\n return \"directional icon did not flip\";\n case \"direction_not_applied\":\n return \"RTL was never applied\";\n }\n}\n\nexport function elide(value: string, max: number): string {\n const text = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;\n}\n\nfunction round(value: number): number {\n return Math.round(value);\n}\n"]}
@@ -0,0 +1,129 @@
1
+ import type { ImageProbe, JsonLdBlock, PageSeo, PerfMetrics, RobotsFetch } from "../engine/seo.js";
2
+ /**
3
+ * What counts as an SEO problem.
4
+ *
5
+ * Everything here is pure: it takes what `engine/seo.ts` read off the page and
6
+ * returns findings. Nothing in this file opens a browser, which is the point —
7
+ * "is this canonical wrong?", "does this robots.txt block that path?" and
8
+ * "which properties is this Product missing?" are all judgements with edge
9
+ * cases, and judgements need unit tests that run in milliseconds.
10
+ *
11
+ * The type import above is type-only, so there is no runtime dependency on the
12
+ * engine and no cycle: the engine reads, this judges, the tool prints.
13
+ */
14
+ export declare const SEO_AREAS: readonly ["indexing", "content", "headings", "social", "images", "structured_data", "performance"];
15
+ export type SeoArea = (typeof SEO_AREAS)[number];
16
+ /**
17
+ * `problem` will cost traffic, `warning` should be fixed, `info` is a
18
+ * measurement with no verdict attached, `pass` is a check that ran and was
19
+ * happy. Passes are reported rather than dropped for the same reason the
20
+ * accessibility tool counts the rules that passed: without them, a short
21
+ * report is indistinguishable from an audit that never ran.
22
+ */
23
+ export type SeoLevel = "problem" | "warning" | "info" | "pass";
24
+ export interface SeoFinding {
25
+ area: SeoArea;
26
+ level: SeoLevel;
27
+ /** What was checked — "Title", "og:image", "robots.txt". */
28
+ label: string;
29
+ /** What was found, including the value itself where it is short enough to be worth seeing. */
30
+ detail: string;
31
+ /** What to do about it. Only on problems and warnings. */
32
+ fix?: string;
33
+ }
34
+ export interface SeoReport {
35
+ findings: SeoFinding[];
36
+ problems: number;
37
+ warnings: number;
38
+ passes: number;
39
+ }
40
+ export interface SeoResponseInfo {
41
+ status: number;
42
+ headers: Record<string, string>;
43
+ }
44
+ export interface SeoAuditInput {
45
+ /** The URL the caller asked for, which is not necessarily the one that answered. */
46
+ requested_url: string;
47
+ page: PageSeo;
48
+ response?: SeoResponseInfo;
49
+ robots?: RobotsFetch;
50
+ robots_user_agent: string;
51
+ og_image?: ImageProbe;
52
+ performance?: PerfMetrics;
53
+ }
54
+ /**
55
+ * Judge a page. The order of the checks is the order they are printed in, and
56
+ * it runs from "will this page be in the index at all" down to "how fast is
57
+ * it" — a `noindex` makes every other finding academic, so it goes first.
58
+ */
59
+ export declare function judgeSeo(input: SeoAuditInput): SeoReport;
60
+ export interface RobotsVerdict {
61
+ allowed: boolean;
62
+ /** The directive that decided it, e.g. `Disallow: /admin/`. */
63
+ rule?: string;
64
+ /** The user-agent group it came from. */
65
+ group?: string;
66
+ sitemaps: string[];
67
+ reason: string;
68
+ }
69
+ /**
70
+ * Decide whether `userAgent` may crawl `url`, per RFC 9309.
71
+ *
72
+ * Two rules do the work. Group selection: the most specific user-agent line
73
+ * that matches wins, and `*` is the fallback — so a `Disallow: /` under
74
+ * `User-agent: *` does not apply to Googlebot if Googlebot has a group of its
75
+ * own. Rule selection: the *longest* matching path wins regardless of order,
76
+ * and Allow beats Disallow on a tie. That is why `Disallow: /` followed by
77
+ * `Allow: /public/` permits `/public/page` — reading top to bottom would get
78
+ * it exactly wrong.
79
+ *
80
+ * `*` matches any run of characters and a trailing `$` anchors to the end.
81
+ */
82
+ export declare function evaluateRobots(text: string, url: string, userAgent: string): RobotsVerdict;
83
+ /** A robots path pattern against a request path. `*` is any run, `$` anchors the end. */
84
+ export declare function matchesRobotsPath(pattern: string, path: string): boolean;
85
+ export interface SchemaExpectation {
86
+ required: string[];
87
+ recommended: string[];
88
+ }
89
+ /**
90
+ * What Google's rich-result documentation asks for, per type.
91
+ *
92
+ * Deliberately short. Only types worth a rich result are listed, and only the
93
+ * properties whose absence actually costs one — a validator that reports every
94
+ * optional property of every schema.org type produces noise nobody reads, and
95
+ * schema.org itself requires almost nothing.
96
+ */
97
+ export declare const SCHEMA_EXPECTATIONS: Readonly<Record<string, SchemaExpectation>>;
98
+ export declare function expectationsFor(type: string): SchemaExpectation | undefined;
99
+ export interface JsonLdNode {
100
+ type: string;
101
+ /** Property names present on the node, `@`-prefixed keys excluded. */
102
+ keys: string[];
103
+ name?: string;
104
+ missing_required: string[];
105
+ missing_recommended: string[];
106
+ /** Whether this type has expectations at all — an unknown type is reported, not judged. */
107
+ known: boolean;
108
+ }
109
+ export interface JsonLdParsed {
110
+ /** 1-based position of the block on the page. */
111
+ index: number;
112
+ ok: boolean;
113
+ error?: string;
114
+ nodes: JsonLdNode[];
115
+ }
116
+ /**
117
+ * Parse the JSON-LD blocks and check each node against its type.
118
+ *
119
+ * `@graph` is flattened and a top-level array is iterated, because both are
120
+ * ordinary ways to put several entities in one block. Nesting below that is
121
+ * left alone: a Product's `offers` is checked as part of the Product's own
122
+ * expectations, and walking arbitrarily deep would report the same missing
123
+ * property from three directions.
124
+ */
125
+ export declare function parseJsonLd(blocks: JsonLdBlock[]): JsonLdParsed[];
126
+ /** Read a meta value however the page spelled it: `property` first, then `name`. */
127
+ export declare function metaValues(page: PageSeo, key: string): string[];
128
+ /** Two URLs that address the same page. The fragment is not part of the address. */
129
+ export declare function sameAddress(a: string, b: string): boolean;