@seybel/breakhound 1.0.2 → 1.0.4
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.
- package/README.md +15 -8
- package/dist/cli.js +146 -30
- package/dist/index.d.cts +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/inject.js +38 -9
- package/dist/schema.json +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,7 +78,13 @@ npx @seybel/breakhound --url https://staging.example.com # every route, on ano
|
|
|
78
78
|
| Angular | your route definitions, from `src/main.ts` on, with lazily loaded `loadChildren` routes under their path |
|
|
79
79
|
| Anything else | links followed from the home page (up to 50 pages, a few per section) |
|
|
80
80
|
|
|
81
|
-
**Dynamic routes** like `/blog/[slug]` are scanned once, using a real link found on your pages (routes that share a template share a layout).
|
|
81
|
+
**Dynamic routes** like `/blog/[slug]` are scanned once, using a real link found on your pages (routes that share a template share a layout). Routes below one reuse its values: once `/cohorts/css-101` is found, `/cohorts/[slug]/settings` is scanned as `/cohorts/css-101/settings`. If something is still missing, it's listed with a hint naming the parameters. Give them a real value once, by name, and every route that uses them is filled in:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
params: { slug: 'css-101', submissionId: '42' }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or give one route its own URL, which wins over `params`:
|
|
82
88
|
|
|
83
89
|
```ts
|
|
84
90
|
samples: { '/products/[id]': '/products/42' }
|
|
@@ -122,15 +128,15 @@ Chromium finds most layout bugs, but Safari and Firefox lay some things out diff
|
|
|
122
128
|
|
|
123
129
|
Two differences you'll meet early: Safari gives buttons, inputs and selects an 11px font unless you set one (Chrome and Firefox use about 13px), so unstyled controls show up as tiny text only in WebKit, with `font: inherit` as the fix (that's WebKit on a Mac: Playwright's WebKit on Linux, as in most CI, draws them like Chrome); and Firefox breaks long URLs at their slashes, so a URL that pushes the page wide in Chrome and Safari can wrap fine in Firefox.
|
|
124
130
|
|
|
125
|
-
### Menus, dialogs and expandable sections
|
|
131
|
+
### Menus, dialogs, tabs and expandable sections
|
|
126
132
|
|
|
127
|
-
A lot of what breaks on a phone is hidden until you tap: the mobile menu, a sign-up modal, an FAQ answer. After scanning a page as it loads, breakhound opens what it finds there one at a time and sweeps the widths where each can be opened. That covers buttons with `aria-expanded`, `<details>`, popovers, dialogs, Bootstrap toggles, and buttons that are clearly a menu (`hamburger`, `menu-toggle`, an accessible name of "Menu"). Only what breaks while something is open gets reported, with what to open first:
|
|
133
|
+
A lot of what breaks on a phone is hidden until you tap: the mobile menu, a sign-up modal, an FAQ answer, the second tab. After scanning a page as it loads, breakhound opens what it finds there one at a time and sweeps the widths where each can be opened. That covers buttons with `aria-expanded`, `<details>`, popovers, dialogs, tabs (`role="tab"`, as in Radix, shadcn/ui, Headless UI and MUI), Bootstrap toggles, and buttons that are clearly a menu (`hamburger`, `menu-toggle`, an accessible name of "Menu"). Only what breaks while something is open gets reported, with what to open first:
|
|
128
134
|
|
|
129
135
|
```
|
|
130
136
|
✖ 320–399px Page scrolls horizontally (80px wider than the viewport), with "Menu" open
|
|
131
137
|
```
|
|
132
138
|
|
|
133
|
-
It never clicks a link to another page or a button that submits a form, and it opens only one question of an FAQ, since they all share a layout. It opens up to 5 per page. Set `states` to change that, or `--no-states` (or `data-breakhound-ignore="states"` on an element) to leave them closed.
|
|
139
|
+
It never clicks a link to another page or a button that submits a form, and it opens only one question of an FAQ, since they all share a layout, but every tab, since each shows something different. It opens up to 5 per page. Set `states` to change that, or `--no-states` (or `data-breakhound-ignore="states"` on an element) to leave them closed.
|
|
134
140
|
|
|
135
141
|
### A second opinion from a vision model (optional)
|
|
136
142
|
|
|
@@ -153,7 +159,7 @@ The folder is for you, not your repo, so it keeps itself out of git: breakhound
|
|
|
153
159
|
|
|
154
160
|
## Chrome extension
|
|
155
161
|
|
|
156
|
-
The breakhound extension runs the same checks on the tab you have open, with nothing to install in your project. Get it from the [Chrome Web Store](https://chromewebstore.google.com/
|
|
162
|
+
The breakhound extension runs the same checks on the tab you have open, with nothing to install in your project. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/ddnmhnjmcokbfabeileblceehjhefkpc); it works on any page Chrome can open, from localhost to production. The quickest way in is to right-click the page and choose **Scan with breakhound**: the breakhound panel opens in Chrome's side panel and starts right away. Clicking the breakhound icon in the toolbar opens it ready to scan, and the same panel is a **breakhound** tab in DevTools. The page grows steadily from 320px to 1920px (or the widths you set) while the timeline fills in live: each run of widths that share a layout, every width where the layout changes, and where something breaks. Then you get:
|
|
157
163
|
|
|
158
164
|
- the issues, each with its exact width ranges, the element, the numbers and the likely fix
|
|
159
165
|
- the timeline: the layout bands, the media query widths, the changes no media or container query explains, and a bar for each issue across the widths it covers
|
|
@@ -296,7 +302,8 @@ Running breakhound through `npx` without installing it, you get `breakhound.conf
|
|
|
296
302
|
| `baseUrl` | Scan this server instead of finding or starting the dev server |
|
|
297
303
|
| `routes` | Scan exactly these routes instead of discovering them |
|
|
298
304
|
| `include`, `exclude` | Route globs: `*` is one segment, `**` any number |
|
|
299
|
-
| `samples` | A real URL for
|
|
305
|
+
| `samples` | A real URL for a dynamic route |
|
|
306
|
+
| `params` | Real values for dynamic route parameters by name, for every route that uses them |
|
|
300
307
|
| `maxRoutes` | Most routes per run (default 100) |
|
|
301
308
|
| `server` | How to start the app: `{ command: 'npm run preview', port: 4173, timeout: 120 }` |
|
|
302
309
|
| `widths` | `{ min, max, step }` in CSS pixels |
|
|
@@ -305,7 +312,7 @@ Running breakhound through `npx` without installing it, you get `breakhound.conf
|
|
|
305
312
|
| `vision` | `true` to add a vision model's opinion, or `{ model, provider, widths }` |
|
|
306
313
|
| `fix` | The model `breakhound fix` uses: `{ provider: 'ollama', model: 'qwen2.5-coder' }` |
|
|
307
314
|
| `browsers` | Engines to check in: `["chromium", "webkit", "firefox"]` (default Chromium only) |
|
|
308
|
-
| `states` | Most menus, dialogs and expandable sections to open per page (default 5), or `false` |
|
|
315
|
+
| `states` | Most menus, dialogs, tabs and expandable sections to open per page (default 5), or `false` |
|
|
309
316
|
| `concurrency` | Browser tabs to use at once (default half your CPU cores, up to 4) |
|
|
310
317
|
| `memoryLimit` | Most memory the dev server and browser may use together, e.g. `"6GB"`, `"50%"` or `"off"` (default 60% of your memory) |
|
|
311
318
|
|
|
@@ -344,7 +351,7 @@ breakhound mcp Run the MCP server (your agent starts it)
|
|
|
344
351
|
--fail-on <level> Exit 1 at error, warning, info or none (default error)
|
|
345
352
|
--pdf Also write report.pdf
|
|
346
353
|
--no-screenshots Skip screenshots
|
|
347
|
-
--no-states Don't open menus, dialogs and expandable sections
|
|
354
|
+
--no-states Don't open menus, dialogs, tabs and expandable sections
|
|
348
355
|
--browsers <list> Also check other engines, e.g. chromium,webkit,firefox
|
|
349
356
|
--vision Also ask your own vision model what looks off (info only)
|
|
350
357
|
--concurrency <n> Browser tabs to use at once
|
package/dist/cli.js
CHANGED
|
@@ -693,6 +693,8 @@ function describeState(state) {
|
|
|
693
693
|
switch (state.kind) {
|
|
694
694
|
case "dialog":
|
|
695
695
|
return named ? `with the "${state.label}" dialog open` : "with the dialog open";
|
|
696
|
+
case "tab":
|
|
697
|
+
return named ? `with the "${state.label}" tab open` : "with another tab open";
|
|
696
698
|
case "details":
|
|
697
699
|
case "disclosure":
|
|
698
700
|
return named ? `with "${state.label}" expanded` : "with the section expanded";
|
|
@@ -1303,6 +1305,24 @@ function plural(n, word) {
|
|
|
1303
1305
|
function routeLabel(route) {
|
|
1304
1306
|
return route.route || new URL(route.url).pathname;
|
|
1305
1307
|
}
|
|
1308
|
+
function leftOutHint(routes) {
|
|
1309
|
+
const skipped = routes.filter((r) => r.status === "skipped");
|
|
1310
|
+
if (!skipped.length) return null;
|
|
1311
|
+
const names = /* @__PURE__ */ new Set();
|
|
1312
|
+
for (const r of skipped) {
|
|
1313
|
+
const named = r.error?.match(/nothing gives (.+) a value/)?.[1] ?? "";
|
|
1314
|
+
for (const m of named.matchAll(/\[([^\]]+)\]/g)) names.add(m[1]);
|
|
1315
|
+
}
|
|
1316
|
+
const key = (n) => /^[A-Za-z_$][\w$]*$/.test(n) ? n : `'${n}'`;
|
|
1317
|
+
if (names.size) return `params: { ${[...names].map((n) => `${key(n)}: '1'`).join(", ")} }`;
|
|
1318
|
+
const first = skipped[0].route;
|
|
1319
|
+
const example = first.replace(/\[\[?\.\.\.[^\]]+\]\]?/g, "a/b").replace(/\[\[?[^\]]+\]\]?/g, "1");
|
|
1320
|
+
return `samples: { '${first}': '${example}' }`;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ../report/src/brand.ts
|
|
1324
|
+
var MARK_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFEAAAA8CAYAAAAe5VayAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAUaADAAQAAAABAAAAPAAAAADqhB1AAAAHF0lEQVR4Ae2be4gVVRzHXR/5iNLUNtPEAntiUopZWLHYVpqm/SeBEUREGAhFDyhpNXqtRbWBgQ9SCQItQYiIKKVSekhRmZklWoumZqui0aKuun2+60yc++PM3p25M3vHe/3Bl3PO78z5/b7nd8+cOefM3B49zkrJEagp2cIZbKC9vb0/9C8FQ8EJsKempqaZ9KwUiwDBqwXzwTZwFIRymMxnYDboWcxO1dYTnLtAMygma7mgtmoDFdVxgvJIELmtpE+At4NyVPJOlK2q1AdBO0n6LBigIJB+ADoT3ep3V2XAbKcJxJNBpOa5dei+DPSdJceo/AQ8BsaCXq6NqsjTaY08yfegj9tpyp+qIoYooJvAc2A8qPyHD51sAKHMdwOoPBWLwsoE6V7aDJSdio0kHWygf/PVyUC2hRkn3eDk42bX0eCfuI3OmOsVQGBlpu0AF4wCWhvGlT9pMMraq5gynQvnQBuYubaTXNATfGUvLFI+SP3t1lbFlOmcdiFR8rGvo1wcrhX3k38GTAEzwQKg3Yv0WuoofR9c77NTETo6Nw8Uk2m2szR4A7SA8bZOZfQXgcuV+uorQkfnBoG3QDHZyQUaTTe7Haf8EWh0dVWVp/OTwQ+gM2mlsgkMAS8DPUi0cK4FU4HqJ1RV4NRZOn0Z0Og7DjqTE1Q+7waI8v1AT9dDQPUrQcUu9dy+d+Tp7GiwEPwNuir/cqECrrPD0I5G5QxQD3qH+qRpaoeykNGW6ryAiOzq1xXBNg46WwJ97AS72hXcAu4F08H5wJWtFNaCVnADuA2cC6ysRPEAXE7ZirKX6aQmdi0tfgR7Auwj1dzzOaiLS5I2l4B7wGLwB7DyO4ol4A7QcSIT+qCsQ4KNwCf6IfIlsLwCfOtji24ZiFwWUNcHaHLX8kEPiDlAQfsOaKMfio6vdgGdpLwAFLiOPWtUNKiX3V+BFQU39VOYxLczZC6kE+vBGNOZg5TncNusMvqCIu2HodDaTOlIEN6mx8hrT3oA7ALNYDf2DpN2WbD/EBcvNg2OUB6DLdktv0DyTWDlCIq68rPreIJfDRd3RIurRnU+dhwQ0ZylJYKVp/MQQHGA2EigH9XKxLQ5Jl0f6Wk5yJD5i/ISoytnMWqqitIn5po0iOM8Hjcz1yReynjslapqx4BgJTdBHGuZUf7Zo6sKVeyRyASjBfWVnuhs9ujKqdKI84063+gsiWfsIOJNy5GLjVftArRzyJNEBTF1jkmCeA0szjFMNBfuMLpyF7Um9L0DGZ02sSRBvM5DYjs6LY5zIzzkDkFmi4fQw0xJfT36xKpYQcS5tkx3erxtgnTqc43HT1zVck+DSejW0JdbgU5zYsXAYy+eCoePAittKHR6kjuBV2+wwhJ2ynp3fGPmxHGiU5px4DWggFlZlDmJEhxAti9oAq2WeFCuL8F88aY4GQh0xOULnjisBgVHUcWtlucKeC4VYY9MzpQRDid5nEq1GzwFCr5tyZRMQuNwHAB0xGYPI1B1SOZBVKCsiMyshH3q1mbw1Jz4ru2AU9bhceoHEv93EuM1YL3j0M1qQtZTLtcCxwdd0k5+Hfl6MBT4djXp9AvjvUAD+An4RCfaqa630mF+2grcNAo3eYh/jc73DiZN94W2cKg5ZTmwooeNPdUubFzGEtx05qn3PFbuS5tW0YUmi2i9RbPH7OKhN3lDlMmp6LzTt3LYmTbfokEMHPqewsepy9VWzwRHAbQvpbSr0qBIVboaRN9+eS9MmlNlk64xvRG0Dw394edoum66/qXsVI/jX7jVfacknkvLogrfHrrO2yjoDkpVio5EZuUX8TjF43WjR5cn1QUeMhqJQvcJAWy0j7ag/Bvp8O5jEt8T/BZ4uO9HVxvfWsIWOHvJQ0IqfdZxbUKz3dIMfvqMeCOwshmF7yGZPi8cvWq9B+UdpLkOoKIBx9ngVMDZTZrSj5bHIh5fcb06+S3kr/I0yZUKjtOAPk63oqOwbAcADrRXjpoD9ZHRqFxFyyEDt35AX4O9DqJOaxqcJtlkcf448MkGlPbtXjYkEliFmwKogxL3f8sUC0QHytphZSc40OHr7gK3pwsfkgzOznPpluEn7jrS8skXKGeU7qULFnA0AvjmkZu60Lysl8B7MNgDQtF33O+BbuMeLra1pxSszILMBGC3T/a6cpZ1m/YD2oIuBPonwFygs8LpoHvuJBwNBweAT/RN3zKQ7ZxCr5MIvHRUVweGAb3OWAq0qA5lOxnf3j+Ju+g2OBGBltBrRJrL16LqFXwngm8ieEu9Orr3pdeEt7M+uSj2OW/2v2by/oymaWc/sqYk39lico9Oy44gBgevjej1TU0YUKUhFOD+IK+yCmIrgHiG/MO8yidBZkEseGDwa+kjdPdjJdWHD5xDeT76grsGxAigNOQc8lcQ98Ff6VnJYwT+A7n3TF1PsRH9AAAAAElFTkSuQmCC";
|
|
1325
|
+
var WORDMARK_PNG = "data:image/png;base64,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";
|
|
1306
1326
|
|
|
1307
1327
|
// ../report/src/markdown.ts
|
|
1308
1328
|
var SEVERITY_ICON = { error: "\u2716", warning: "\u26A0", info: "\u2139" };
|
|
@@ -1364,7 +1384,7 @@ function renderSummary(result) {
|
|
|
1364
1384
|
[
|
|
1365
1385
|
"Routes left out",
|
|
1366
1386
|
result.routes.filter((r) => r.status === "redirect" || r.status === "skipped"),
|
|
1367
|
-
result.routes
|
|
1387
|
+
leftOutHint(result.routes) ? `Give dynamic routes real values in your config, e.g. \`${leftOutHint(result.routes)}\` (replace the 1s with real ones).` : void 0
|
|
1368
1388
|
]
|
|
1369
1389
|
];
|
|
1370
1390
|
for (const [title, routes, hint] of sections) {
|
|
@@ -1547,6 +1567,7 @@ function buildData(result, options) {
|
|
|
1547
1567
|
baseUrl: result.project.baseUrl
|
|
1548
1568
|
},
|
|
1549
1569
|
summary: result.summary,
|
|
1570
|
+
leftOutHint: leftOutHint(result.routes),
|
|
1550
1571
|
routes: result.routes.map((r) => ({
|
|
1551
1572
|
route: routeLabel(r),
|
|
1552
1573
|
url: r.url,
|
|
@@ -1586,7 +1607,7 @@ function renderHtml(result, options = {}) {
|
|
|
1586
1607
|
<body>
|
|
1587
1608
|
<header class="top">
|
|
1588
1609
|
<div class="wrap top-inner">
|
|
1589
|
-
<div class="brand"
|
|
1610
|
+
<div class="brand" role="img" aria-label="breakhound"><span class="mark"></span><span class="word"></span></div>
|
|
1590
1611
|
<div class="top-actions">
|
|
1591
1612
|
<button type="button" class="btn primary" id="copy-all">Copy fix prompt</button>
|
|
1592
1613
|
</div>
|
|
@@ -1657,8 +1678,10 @@ code, .mono { font-family: var(--mono); font-size: 13px; }
|
|
|
1657
1678
|
|
|
1658
1679
|
.top { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); }
|
|
1659
1680
|
.top-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 56px; }
|
|
1660
|
-
.brand { display: flex; align-items: center; gap:
|
|
1661
|
-
.mark {
|
|
1681
|
+
.brand { display: flex; align-items: center; gap: 7px; color: var(--text); }
|
|
1682
|
+
.brand .mark, .brand .word { display: inline-block; background: currentColor; mask-position: center; mask-size: contain; mask-repeat: no-repeat; }
|
|
1683
|
+
.brand .mark { width: 27px; height: 20px; mask-image: url("${MARK_PNG}"); }
|
|
1684
|
+
.brand .word { width: 95px; height: 18px; mask-image: url("${WORDMARK_PNG}"); }
|
|
1662
1685
|
.btn { border: 1px solid var(--border); background: var(--surface); border-radius: 8px; padding: 7px 12px; cursor: pointer; white-space: nowrap; }
|
|
1663
1686
|
.btn:hover { border-color: var(--tick); }
|
|
1664
1687
|
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; font-weight: 600; }
|
|
@@ -1773,7 +1796,7 @@ dd code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
|
|
|
1773
1796
|
a { color: inherit; }
|
|
1774
1797
|
}
|
|
1775
1798
|
`;
|
|
1776
|
-
var JS =
|
|
1799
|
+
var JS = `
|
|
1777
1800
|
(() => {
|
|
1778
1801
|
const D = JSON.parse(document.getElementById('bh-data').textContent);
|
|
1779
1802
|
const $ = (id) => document.getElementById(id);
|
|
@@ -1792,7 +1815,7 @@ var JS = String.raw`
|
|
|
1792
1815
|
for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) n.append(kid);
|
|
1793
1816
|
return n;
|
|
1794
1817
|
};
|
|
1795
|
-
const range = (r) => (r.from === r.to ? r.from + 'px' : r.from + '
|
|
1818
|
+
const range = (r) => (r.from === r.to ? r.from + 'px' : r.from + '\u2013' + r.to + 'px');
|
|
1796
1819
|
const ranges = (rs) => rs.map(range).join(', ');
|
|
1797
1820
|
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
|
|
1798
1821
|
|
|
@@ -1818,10 +1841,10 @@ var JS = String.raw`
|
|
|
1818
1841
|
const started = new Date(D.meta.startedAt);
|
|
1819
1842
|
const scanned = D.routes.filter((r) => r.status === 'scanned');
|
|
1820
1843
|
$('meta').textContent = [
|
|
1821
|
-
D.meta.baseUrl, plural(scanned.length, 'page'), min + '
|
|
1844
|
+
D.meta.baseUrl, plural(scanned.length, 'page'), min + '\u2013' + max + 'px',
|
|
1822
1845
|
started.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }),
|
|
1823
1846
|
(D.meta.durationMs / 1000).toFixed(1) + 's',
|
|
1824
|
-
].filter(Boolean).join('
|
|
1847
|
+
].filter(Boolean).join(' \xB7 ');
|
|
1825
1848
|
$('version').textContent = D.meta.version;
|
|
1826
1849
|
$('copy-all').addEventListener('click', () => copy(D.prompt, 'Fix prompt copied. Paste it into your coding agent.'));
|
|
1827
1850
|
if (!s.issues) $('copy-all').hidden = true;
|
|
@@ -1891,11 +1914,9 @@ var JS = String.raw`
|
|
|
1891
1914
|
}
|
|
1892
1915
|
const skipped = D.routes.filter((r) => r.status === 'skipped' || r.status === 'redirect');
|
|
1893
1916
|
if (skipped.length) {
|
|
1894
|
-
const dynamic = skipped.find((r) => r.status === 'skipped');
|
|
1895
|
-
const example = dynamic && dynamic.route.replace(/\[\[?\.\.\.[^\]]+\]\]?/g, 'a/b').replace(/\[\[?[^\]]+\]\]?/g, '1');
|
|
1896
1917
|
$('failed').append(el('div', { class: 'callout' },
|
|
1897
1918
|
el('h2', { text: plural(skipped.length, 'route') + ' left out' }),
|
|
1898
|
-
|
|
1919
|
+
D.leftOutHint ? el('p', {}, 'Give dynamic routes real values in your config (replace the 1s with real ones): ', el('code', { text: D.leftOutHint })) : false,
|
|
1899
1920
|
listing(skipped)));
|
|
1900
1921
|
}
|
|
1901
1922
|
const shaky = D.routes.filter((r) => r.warnings.length);
|
|
@@ -2217,9 +2238,6 @@ function globMatches(glob, path) {
|
|
|
2217
2238
|
}
|
|
2218
2239
|
return new RegExp(`^${source}/?$`).test(path);
|
|
2219
2240
|
}
|
|
2220
|
-
function examplePath(path) {
|
|
2221
|
-
return path.replace(/\[\[?\.\.\.[^\]]+\]\]?/g, "a/b").replace(/\[\[?[^\]]+\]\]?/g, "1");
|
|
2222
|
-
}
|
|
2223
2241
|
|
|
2224
2242
|
// ../routes/src/syntax.ts
|
|
2225
2243
|
function joinPaths(base, path) {
|
|
@@ -3055,26 +3073,44 @@ function detectProject(cwd) {
|
|
|
3055
3073
|
}
|
|
3056
3074
|
|
|
3057
3075
|
// ../routes/src/queue.ts
|
|
3076
|
+
function namesIn(label) {
|
|
3077
|
+
return [...label.matchAll(/\[\[?(?:\.\.\.)?([^\]]+)\]\]?/g)].map((m) => m[1]);
|
|
3078
|
+
}
|
|
3079
|
+
function isPlainParam(s) {
|
|
3080
|
+
return s.kind === "param" && !s.optional && namesIn(s.label).length === 1 && s.label.startsWith("[") && s.label.endsWith("]");
|
|
3081
|
+
}
|
|
3082
|
+
function prefixOf(segments, i) {
|
|
3083
|
+
return `/${segments.slice(0, i + 1).map((s) => s.kind === "static" ? s.value : s.label).join("/")}`;
|
|
3084
|
+
}
|
|
3058
3085
|
function trimSlash(path) {
|
|
3059
3086
|
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
3060
3087
|
}
|
|
3061
3088
|
var RouteQueue = class {
|
|
3062
|
-
constructor(baseUrl, samples, crawler, hashRouting = false) {
|
|
3089
|
+
constructor(baseUrl, samples, crawler, hashRouting = false, params = {}) {
|
|
3063
3090
|
this.baseUrl = baseUrl;
|
|
3064
3091
|
this.samples = samples;
|
|
3065
3092
|
this.crawler = crawler;
|
|
3066
3093
|
this.hashRouting = hashRouting;
|
|
3094
|
+
this.params = params;
|
|
3067
3095
|
this.basePath = baseUrl ? new URL(baseUrl).pathname.replace(/\/+$/, "") : "";
|
|
3068
3096
|
}
|
|
3069
3097
|
baseUrl;
|
|
3070
3098
|
samples;
|
|
3071
3099
|
crawler;
|
|
3072
3100
|
hashRouting;
|
|
3101
|
+
params;
|
|
3073
3102
|
jobs = [];
|
|
3074
3103
|
/** App paths already scanned or planned, so redirects to them aren't scanned twice. */
|
|
3075
3104
|
planned = /* @__PURE__ */ new Set();
|
|
3076
3105
|
/** Dynamic routes still waiting for a link that fits them. */
|
|
3077
3106
|
waiting = /* @__PURE__ */ new Map();
|
|
3107
|
+
/** For routes left waiting at the end: the parameters nothing gave a value. */
|
|
3108
|
+
missing = /* @__PURE__ */ new Map();
|
|
3109
|
+
/**
|
|
3110
|
+
* Values parameters took in real URLs, under the route they belong to: `/cohorts/[slug]` →
|
|
3111
|
+
* `css-101`. Routes below it reuse them, since a child of a real page is real too.
|
|
3112
|
+
*/
|
|
3113
|
+
seen = /* @__PURE__ */ new Map();
|
|
3078
3114
|
patterns = /* @__PURE__ */ new Map();
|
|
3079
3115
|
order = 0;
|
|
3080
3116
|
/** Where the app is mounted on its server, e.g. "/docs"; "" at the root. */
|
|
@@ -3128,17 +3164,84 @@ var RouteQueue = class {
|
|
|
3128
3164
|
return;
|
|
3129
3165
|
}
|
|
3130
3166
|
const sample = this.samples[pattern.path];
|
|
3131
|
-
if (sample)
|
|
3132
|
-
|
|
3167
|
+
if (sample) {
|
|
3168
|
+
const samplePath = /^https?:\/\//i.test(sample) ? this.appPath(new URL(sample).pathname) : sample;
|
|
3169
|
+
if (samplePath !== null) this.learn(pattern, samplePath);
|
|
3170
|
+
this.add({ url: this.urlFor(sample), route: pattern.path });
|
|
3171
|
+
} else this.waiting.set(pattern.path, pattern);
|
|
3172
|
+
}
|
|
3173
|
+
/** Remembers the values a real URL gives a route's parameters. */
|
|
3174
|
+
learn(pattern, path) {
|
|
3175
|
+
if (pattern.segments.some((s) => s.kind === "rest" || s.kind === "param" && s.optional))
|
|
3176
|
+
return;
|
|
3177
|
+
const parts = path.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
3178
|
+
if (parts.length !== pattern.segments.length) return;
|
|
3179
|
+
pattern.segments.forEach((s, i) => {
|
|
3180
|
+
if (isPlainParam(s)) {
|
|
3181
|
+
const key = prefixOf(pattern.segments, i);
|
|
3182
|
+
if (!this.seen.has(key)) this.seen.set(key, parts[i]);
|
|
3183
|
+
}
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* A URL for a dynamic route from values already known: a parent route's real URL, then the
|
|
3188
|
+
* config's params by name. Null, with the names still needed, when something is missing.
|
|
3189
|
+
*/
|
|
3190
|
+
build(pattern) {
|
|
3191
|
+
const parts = [];
|
|
3192
|
+
const missing = [];
|
|
3193
|
+
pattern.segments.forEach((s, i) => {
|
|
3194
|
+
if (s.kind === "static") {
|
|
3195
|
+
parts.push(s.value);
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
const inherited = isPlainParam(s) ? this.seen.get(prefixOf(pattern.segments, i)) : void 0;
|
|
3199
|
+
if (inherited !== void 0) {
|
|
3200
|
+
parts.push(inherited);
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
const names = namesIn(s.label);
|
|
3204
|
+
const absent = names.filter((n) => this.params[n] === void 0);
|
|
3205
|
+
if (absent.length) {
|
|
3206
|
+
if (!s.optional) missing.push(...absent);
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
let value = s.label;
|
|
3210
|
+
for (const n of names)
|
|
3211
|
+
value = value.replace(/\[\[?(?:\.\.\.)?[^\]]+\]\]?/, () => this.params[n]);
|
|
3212
|
+
parts.push(value.replace(/^\/+|\/+$/g, ""));
|
|
3213
|
+
});
|
|
3214
|
+
return missing.length ? { missing: [...new Set(missing)] } : { path: `/${parts.join("/")}` };
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* Gives the routes no link fitted a URL from known values, once the links have had their
|
|
3218
|
+
* chance. Returns how many were added; the rest stay waiting, with what they're missing.
|
|
3219
|
+
*/
|
|
3220
|
+
fillWaiting() {
|
|
3221
|
+
let added = 0;
|
|
3222
|
+
for (const [key, pattern] of this.waiting) {
|
|
3223
|
+
const built = this.build(pattern);
|
|
3224
|
+
if ("missing" in built) {
|
|
3225
|
+
this.missing.set(key, built.missing);
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
3228
|
+
this.waiting.delete(key);
|
|
3229
|
+
this.missing.delete(key);
|
|
3230
|
+
this.add({ url: this.urlFor(built.path), route: pattern.path });
|
|
3231
|
+
added++;
|
|
3232
|
+
}
|
|
3233
|
+
return added;
|
|
3133
3234
|
}
|
|
3134
3235
|
/** Links from a scanned page: samples for dynamic routes, and new pages when crawling. */
|
|
3135
3236
|
addLinks(found) {
|
|
3136
3237
|
if (!this.baseUrl) return;
|
|
3137
3238
|
const links = found.map((link) => this.linkPath(link)).filter((p) => p !== null && isCrawlable(p));
|
|
3138
|
-
if (this.
|
|
3239
|
+
if (this.patterns.size) {
|
|
3139
3240
|
for (const link of links) {
|
|
3140
3241
|
const route = routeFor(link, this.patterns.values());
|
|
3141
|
-
if (route
|
|
3242
|
+
if (!route?.dynamic) continue;
|
|
3243
|
+
this.learn(route, link);
|
|
3244
|
+
if (this.waiting.has(route.path)) {
|
|
3142
3245
|
this.waiting.delete(route.path);
|
|
3143
3246
|
this.add({ url: this.urlFor(link), route: route.path });
|
|
3144
3247
|
}
|
|
@@ -4193,6 +4296,7 @@ var KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
|
4193
4296
|
"include",
|
|
4194
4297
|
"exclude",
|
|
4195
4298
|
"samples",
|
|
4299
|
+
"params",
|
|
4196
4300
|
"maxRoutes",
|
|
4197
4301
|
"server",
|
|
4198
4302
|
"widths",
|
|
@@ -4239,6 +4343,14 @@ function validateConfig(config) {
|
|
|
4239
4343
|
);
|
|
4240
4344
|
}
|
|
4241
4345
|
}
|
|
4346
|
+
if (config.params !== void 0) {
|
|
4347
|
+
const ok = config.params && typeof config.params === "object" && !Array.isArray(config.params) && Object.values(config.params).every((v) => typeof v === "string" && v !== "");
|
|
4348
|
+
if (!ok) {
|
|
4349
|
+
throw new Error(
|
|
4350
|
+
`params must give dynamic route parameters a real value by name, like { "slug": "css-101" }`
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4242
4354
|
if (config.states !== void 0 && typeof config.states !== "boolean" && !(Number.isInteger(config.states) && config.states >= 0)) {
|
|
4243
4355
|
throw new Error("states must be true, false, or the most menus and dialogs to open per page");
|
|
4244
4356
|
}
|
|
@@ -4885,13 +4997,12 @@ function printNotes(result, hasSavedLogin) {
|
|
|
4885
4997
|
` ${styleText4("yellow", "\u25CB")} ${plural22(login, "page needs", "pages need")} a login. ${how}`
|
|
4886
4998
|
);
|
|
4887
4999
|
}
|
|
4888
|
-
|
|
5000
|
+
const hint = leftOutHint(skipped);
|
|
5001
|
+
if (hint) {
|
|
4889
5002
|
console.log(
|
|
4890
|
-
` ${styleText4("dim", "\u2013")} ${plural22(skipped.length, "dynamic route", "dynamic routes")} had no link to follow. Give
|
|
5003
|
+
` ${styleText4("dim", "\u2013")} ${plural22(skipped.length, "dynamic route", "dynamic routes")} had no link to follow. Give them real values in your config (replace the 1s), e.g.`
|
|
4891
5004
|
);
|
|
4892
|
-
|
|
4893
|
-
if (first)
|
|
4894
|
-
console.log(styleText4("dim", ` samples: { '${first}': '${examplePath(first)}' }`));
|
|
5005
|
+
console.log(styleText4("dim", ` ${hint}`));
|
|
4895
5006
|
}
|
|
4896
5007
|
}
|
|
4897
5008
|
|
|
@@ -5103,6 +5214,9 @@ async function pool(items, size, work) {
|
|
|
5103
5214
|
};
|
|
5104
5215
|
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
|
5105
5216
|
}
|
|
5217
|
+
function leftOutReason(missing) {
|
|
5218
|
+
return missing.length ? `No page links to it, and nothing gives ${missing.map((n) => `[${n}]`).join(", ")} a value.` : "No page links to it, so there was no real URL to try.";
|
|
5219
|
+
}
|
|
5106
5220
|
async function drain(queue, size, work) {
|
|
5107
5221
|
const active = /* @__PURE__ */ new Set();
|
|
5108
5222
|
for (; ; ) {
|
|
@@ -5111,7 +5225,8 @@ async function drain(queue, size, work) {
|
|
|
5111
5225
|
const p = work(job).finally(() => active.delete(p));
|
|
5112
5226
|
active.add(p);
|
|
5113
5227
|
}
|
|
5114
|
-
if (!active.size) return;
|
|
5228
|
+
if (!active.size && !queue.fillWaiting()) return;
|
|
5229
|
+
if (!active.size) continue;
|
|
5115
5230
|
await Promise.race(active);
|
|
5116
5231
|
}
|
|
5117
5232
|
}
|
|
@@ -5159,7 +5274,8 @@ async function scan(req) {
|
|
|
5159
5274
|
app.baseUrl,
|
|
5160
5275
|
config.samples ?? {},
|
|
5161
5276
|
plan.crawl ? new Crawler(max2, (path) => isWanted(path, config)) : null,
|
|
5162
|
-
plan.hashRouting
|
|
5277
|
+
plan.hashRouting,
|
|
5278
|
+
config.params ?? {}
|
|
5163
5279
|
);
|
|
5164
5280
|
for (const url of plan.urls) queue.add({ url, route: routeOf(url, cwd) });
|
|
5165
5281
|
for (const p of plan.patterns) {
|
|
@@ -5307,7 +5423,7 @@ async function scan(req) {
|
|
|
5307
5423
|
url: "",
|
|
5308
5424
|
finalUrl: "",
|
|
5309
5425
|
status: "skipped",
|
|
5310
|
-
error:
|
|
5426
|
+
error: leftOutReason(queue.missing.get(pattern.path) ?? []),
|
|
5311
5427
|
pageErrors: [],
|
|
5312
5428
|
warnings: [],
|
|
5313
5429
|
breakpoints: { observed: [], declared: [], unexplained: [] },
|
|
@@ -5906,8 +6022,8 @@ var TEMPLATE = (baseUrl, typed) => `${typed ? "import { defineConfig } from '@se
|
|
|
5906
6022
|
// Every route is found automatically. To narrow it down:
|
|
5907
6023
|
// include: ['/blog/**'],
|
|
5908
6024
|
// exclude: ['/admin/**'],
|
|
5909
|
-
//
|
|
5910
|
-
//
|
|
6025
|
+
// Real values for dynamic route parameters no page links to, by name:
|
|
6026
|
+
// params: { slug: 'my-first-post', id: '42' },
|
|
5911
6027
|
|
|
5912
6028
|
// Viewport widths to sweep, in CSS pixels.
|
|
5913
6029
|
widths: { min: 320, max: 1920 },
|
|
@@ -7022,7 +7138,7 @@ Scan options
|
|
|
7022
7138
|
--out <dir> Report folder (default .breakhound)
|
|
7023
7139
|
--pdf Also write report.pdf
|
|
7024
7140
|
--no-screenshots Skip issue screenshots
|
|
7025
|
-
--no-states Don't open menus, dialogs and expandable sections
|
|
7141
|
+
--no-states Don't open menus, dialogs, tabs and expandable sections
|
|
7026
7142
|
--browsers <list> Also check other engines, e.g. chromium,webkit,firefox
|
|
7027
7143
|
--vision Also ask your own vision model what looks off (info only;
|
|
7028
7144
|
uses the same model setup as fix)
|
package/dist/index.d.cts
CHANGED
|
@@ -37,8 +37,9 @@ export interface IgnoreRule {
|
|
|
37
37
|
/**
|
|
38
38
|
* What a toggle opens. `menu`: navigation menus, drawers and dropdowns.
|
|
39
39
|
* `disclosure`: anything else with aria-expanded, such as accordions.
|
|
40
|
+
* `tab`: a tab (role="tab") that isn't selected.
|
|
40
41
|
*/
|
|
41
|
-
export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure";
|
|
42
|
+
export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure" | "tab";
|
|
42
43
|
export interface WidthRange {
|
|
43
44
|
from: number;
|
|
44
45
|
to: number;
|
|
@@ -172,6 +173,12 @@ export interface BreakhoundConfig {
|
|
|
172
173
|
exclude?: string[];
|
|
173
174
|
/** A real URL for each dynamic route, e.g. { '/products/[id]': '/products/42' }. */
|
|
174
175
|
samples?: Record<string, string>;
|
|
176
|
+
/**
|
|
177
|
+
* Values for dynamic route parameters by name, for routes no link on your pages fits,
|
|
178
|
+
* e.g. { slug: 'css-101', submissionId: '42' }. Every route using those names is filled in;
|
|
179
|
+
* a parent route's real URL fills its own parameters for the routes below it already.
|
|
180
|
+
*/
|
|
181
|
+
params?: Record<string, string>;
|
|
175
182
|
/** Most routes to scan in one run (default 100). */
|
|
176
183
|
maxRoutes?: number;
|
|
177
184
|
/** How to start the app when it isn't running, if not with its "dev" script. */
|
package/dist/index.d.ts
CHANGED
|
@@ -37,8 +37,9 @@ export interface IgnoreRule {
|
|
|
37
37
|
/**
|
|
38
38
|
* What a toggle opens. `menu`: navigation menus, drawers and dropdowns.
|
|
39
39
|
* `disclosure`: anything else with aria-expanded, such as accordions.
|
|
40
|
+
* `tab`: a tab (role="tab") that isn't selected.
|
|
40
41
|
*/
|
|
41
|
-
export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure";
|
|
42
|
+
export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure" | "tab";
|
|
42
43
|
export interface WidthRange {
|
|
43
44
|
from: number;
|
|
44
45
|
to: number;
|
|
@@ -172,6 +173,12 @@ export interface BreakhoundConfig {
|
|
|
172
173
|
exclude?: string[];
|
|
173
174
|
/** A real URL for each dynamic route, e.g. { '/products/[id]': '/products/42' }. */
|
|
174
175
|
samples?: Record<string, string>;
|
|
176
|
+
/**
|
|
177
|
+
* Values for dynamic route parameters by name, for routes no link on your pages fits,
|
|
178
|
+
* e.g. { slug: 'css-101', submissionId: '42' }. Every route using those names is filled in;
|
|
179
|
+
* a parent route's real URL fills its own parameters for the routes below it already.
|
|
180
|
+
*/
|
|
181
|
+
params?: Record<string, string>;
|
|
175
182
|
/** Most routes to scan in one run (default 100). */
|
|
176
183
|
maxRoutes?: number;
|
|
177
184
|
/** How to start the app when it isn't running, if not with its "dev" script. */
|
package/dist/inject.js
CHANGED
|
@@ -1804,6 +1804,7 @@
|
|
|
1804
1804
|
|
|
1805
1805
|
// ../analyzers/src/states.ts
|
|
1806
1806
|
var CANDIDATES = [
|
|
1807
|
+
'[role="tab"]',
|
|
1807
1808
|
"[aria-expanded]",
|
|
1808
1809
|
"[aria-haspopup]",
|
|
1809
1810
|
"[popovertarget]",
|
|
@@ -1866,6 +1867,8 @@
|
|
|
1866
1867
|
return details.open || details.querySelector(":scope > summary") !== el ? null : "details";
|
|
1867
1868
|
}
|
|
1868
1869
|
if (el.matches('input, select, textarea, [role="combobox"], [contenteditable]')) return null;
|
|
1870
|
+
if (el.getAttribute("role") === "tab")
|
|
1871
|
+
return el.getAttribute("aria-selected") === "true" ? null : "tab";
|
|
1869
1872
|
if (el.hasAttribute("popovertarget")) {
|
|
1870
1873
|
return el.getAttribute("popovertargetaction") === "hide" ? null : "popover";
|
|
1871
1874
|
}
|
|
@@ -1894,10 +1897,18 @@
|
|
|
1894
1897
|
const href = el instanceof HTMLAnchorElement ? el.getAttribute("href") : null;
|
|
1895
1898
|
return !href || href.startsWith("#") || /^javascript:/i.test(href);
|
|
1896
1899
|
}
|
|
1897
|
-
function
|
|
1900
|
+
function sidewaysScroller(el) {
|
|
1901
|
+
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
1902
|
+
const { overflowX } = getComputedStyle(p);
|
|
1903
|
+
if ((overflowX === "auto" || overflowX === "scroll") && p.scrollWidth > p.clientWidth) return p;
|
|
1904
|
+
}
|
|
1905
|
+
return null;
|
|
1906
|
+
}
|
|
1907
|
+
function isShown(el, scrolls = false) {
|
|
1898
1908
|
const r = el.getBoundingClientRect();
|
|
1899
1909
|
if (r.width < 1 || r.height < 1) return false;
|
|
1900
|
-
if (r.right <= 0 || r.left >= window.innerWidth)
|
|
1910
|
+
if ((r.right <= 0 || r.left >= window.innerWidth) && !(scrolls && sidewaysScroller(el)))
|
|
1911
|
+
return false;
|
|
1901
1912
|
const check = el.checkVisibility;
|
|
1902
1913
|
if (check && !check.call(el, { opacityProperty: true, visibilityProperty: true })) return false;
|
|
1903
1914
|
if (!check && getComputedStyle(el).visibility === "hidden") return false;
|
|
@@ -1927,6 +1938,7 @@
|
|
|
1927
1938
|
menu: 0,
|
|
1928
1939
|
dialog: 0,
|
|
1929
1940
|
popover: 1,
|
|
1941
|
+
tab: 1,
|
|
1930
1942
|
disclosure: 2,
|
|
1931
1943
|
details: 3
|
|
1932
1944
|
};
|
|
@@ -1937,7 +1949,7 @@
|
|
|
1937
1949
|
if (out.length >= MAX_TOGGLES) break;
|
|
1938
1950
|
if (el.closest(DEV_OVERLAYS)) continue;
|
|
1939
1951
|
const kind = kindOf(el);
|
|
1940
|
-
if (!kind || !safeToClick(el) || !isShown(el) || ignored(el, rules)) continue;
|
|
1952
|
+
if (!kind || !safeToClick(el) || !isShown(el, kind === "tab") || ignored(el, rules)) continue;
|
|
1941
1953
|
const panel = targetOf(el, "aria-controls") ?? targetOf(el, "popovertarget");
|
|
1942
1954
|
if (panel) {
|
|
1943
1955
|
if (panels.has(panel)) continue;
|
|
@@ -1948,19 +1960,30 @@
|
|
|
1948
1960
|
selector: cachedSelector(el),
|
|
1949
1961
|
kind,
|
|
1950
1962
|
label: shortLabel(el, kind),
|
|
1951
|
-
|
|
1963
|
+
// Each tab shows different content, unlike the questions of an FAQ.
|
|
1964
|
+
group: kind === "tab" ? `tab|${cachedSelector(el)}` : groupOf(el, kind),
|
|
1952
1965
|
priority: PRIORITY[kind] * 2 + (inHeader ? 0 : 1)
|
|
1953
1966
|
});
|
|
1954
1967
|
}
|
|
1955
1968
|
return out;
|
|
1956
1969
|
}
|
|
1957
|
-
|
|
1970
|
+
var toggles = /* @__PURE__ */ new Map();
|
|
1971
|
+
function toggleOf(selector) {
|
|
1972
|
+
const known = toggles.get(selector);
|
|
1973
|
+
if (known?.isConnected) return known;
|
|
1958
1974
|
const el = resolveSelector(selector);
|
|
1975
|
+
if (el) toggles.set(selector, el);
|
|
1976
|
+
return el;
|
|
1977
|
+
}
|
|
1978
|
+
function toggleState(selector) {
|
|
1979
|
+
const el = toggleOf(selector);
|
|
1959
1980
|
if (!el) return { shown: false, open: null };
|
|
1960
|
-
const shown = isShown(el);
|
|
1981
|
+
const shown = isShown(el, el.getAttribute("role") === "tab");
|
|
1961
1982
|
if (el.matches("details > summary")) {
|
|
1962
1983
|
return { shown, open: el.parentElement.open };
|
|
1963
1984
|
}
|
|
1985
|
+
if (el.getAttribute("role") === "tab")
|
|
1986
|
+
return { shown, open: el.getAttribute("aria-selected") === "true" };
|
|
1964
1987
|
const expanded = el.getAttribute("aria-expanded");
|
|
1965
1988
|
if (expanded === "true" || expanded === "false") return { shown, open: expanded === "true" };
|
|
1966
1989
|
const target = targetOf(el, "popovertarget") ?? targetOf(el, "commandfor");
|
|
@@ -1975,17 +1998,23 @@
|
|
|
1975
1998
|
return { shown, open: null };
|
|
1976
1999
|
}
|
|
1977
2000
|
function setDetails(selector, open) {
|
|
1978
|
-
const details =
|
|
2001
|
+
const details = toggleOf(selector)?.parentElement;
|
|
1979
2002
|
if (details instanceof HTMLDetailsElement) details.open = open;
|
|
1980
2003
|
}
|
|
1981
2004
|
function clickPoint(selector) {
|
|
1982
|
-
const el =
|
|
2005
|
+
const el = toggleOf(selector);
|
|
1983
2006
|
if (!el) return null;
|
|
1984
2007
|
let r = el.getBoundingClientRect();
|
|
1985
2008
|
if (r.top < 0 || r.bottom > window.innerHeight) {
|
|
1986
2009
|
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1987
2010
|
r = el.getBoundingClientRect();
|
|
1988
2011
|
}
|
|
2012
|
+
const row = r.left < 0 || r.right > window.innerWidth ? sidewaysScroller(el) : null;
|
|
2013
|
+
if (row) {
|
|
2014
|
+
const box = row.getBoundingClientRect();
|
|
2015
|
+
row.scrollLeft += r.left + r.width / 2 - (box.left + box.width / 2);
|
|
2016
|
+
r = el.getBoundingClientRect();
|
|
2017
|
+
}
|
|
1989
2018
|
const x = Math.min(window.innerWidth - 1, Math.max(0, r.left + r.width / 2));
|
|
1990
2019
|
const y = Math.min(window.innerHeight - 1, Math.max(0, r.top + r.height / 2));
|
|
1991
2020
|
let hit = document.elementFromPoint(x, y);
|
|
@@ -2006,7 +2035,7 @@
|
|
|
2006
2035
|
const seen = before;
|
|
2007
2036
|
before = null;
|
|
2008
2037
|
if (!seen) return null;
|
|
2009
|
-
const trigger =
|
|
2038
|
+
const trigger = toggleOf(toggle);
|
|
2010
2039
|
let best = null;
|
|
2011
2040
|
for (const el of document.body.querySelectorAll("*")) {
|
|
2012
2041
|
if (seen.has(el) || trigger && (trigger === el || trigger.contains(el))) continue;
|
package/dist/schema.json
CHANGED
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
},
|
|
39
39
|
"description": "A real URL for each dynamic route, e.g. { '/products/[id]': '/products/42' }."
|
|
40
40
|
},
|
|
41
|
+
"params": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"additionalProperties": {
|
|
44
|
+
"type": "string"
|
|
45
|
+
},
|
|
46
|
+
"description": "Values for dynamic route parameters by name, for routes no link on your pages fits, e.g. { slug: 'css-101', submissionId: '42' }. Every route using those names is filled in; a parent route's real URL fills its own parameters for the routes below it already."
|
|
47
|
+
},
|
|
41
48
|
"maxRoutes": {
|
|
42
49
|
"type": "number",
|
|
43
50
|
"description": "Most routes to scan in one run (default 100)."
|