Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
53b65e4
fix(core): decline index coverage on a partial sitemap read
k2kirov Oct 6, 2026
8b2e2c3
fix(core): read only page dates in lastmod verifiability
k2kirov Oct 6, 2026
1c23088
Merge fix/discovery-coverage-truncated-sitemap (#88) into staging
k2kirov Oct 6, 2026
1ef743b
Merge fix/lastmod-verifiability-page-signals (#89) into staging
k2kirov Oct 6, 2026
bc0a865
fix(core): pass crawler bots allowed through the catch-all group
k2kirov Oct 6, 2026
29f79b1
Merge pull request #90 from ForkPoint/fix/crawler-default-allow-passes
k2kirov Oct 6, 2026
ed5af31
fix(core): pass ai-bot-directives when bots inherit the catch-all
k2kirov Oct 6, 2026
d14cbb8
Merge pull request #91 from ForkPoint/fix/ai-bot-directives-wildcard-…
k2kirov Oct 6, 2026
22b5952
fix(core): treat AI-usage silence as not applicable in coherence audit
k2kirov Oct 6, 2026
1f911bc
Merge pull request #92 from ForkPoint/fix/ai-usage-signal-silence-na
k2kirov Oct 6, 2026
6f3ce5b
fix(core): ignore RSL path guesses that return no RSL document
k2kirov Oct 6, 2026
97f11e6
Merge pull request #93 from ForkPoint/fix/rsl-redirected-probe-absent
k2kirov Oct 6, 2026
89783bf
fix(core): read a redirected root .txt probe as absent
k2kirov Oct 6, 2026
bf18644
Merge pull request #94 from ForkPoint/fix/root-text-probe-redirects
k2kirov Oct 6, 2026
c57d0ed
fix(core): stop reading a form Turnstile loader as a bot wall
k2kirov Oct 6, 2026
1da406e
Merge pull request #95 from ForkPoint/fix/turnstile-form-scope-commerce
k2kirov Oct 6, 2026
bbea055
fix(core): read the largest article in the semantic extractor
k2kirov Oct 6, 2026
2f48ce6
Merge pull request #96 from ForkPoint/fix/semantic-extractor-largest-…
k2kirov Oct 6, 2026
0b30f37
fix(core): match stripper class tokens, not substrings
k2kirov Oct 6, 2026
e1a050b
Merge pull request #97 from ForkPoint/fix/aggressive-extractor-class-…
k2kirov Oct 6, 2026
2b65ca5
fix(core): take only sentences as text-fragment answer spans
k2kirov Oct 6, 2026
79e9f67
Merge pull request #98 from ForkPoint/fix/text-fragment-leaf-spans
k2kirov Oct 6, 2026
4a5a998
fix(core): read every JSON-LD shape in feed parity
k2kirov Oct 6, 2026
90aa814
Merge pull request #99 from ForkPoint/fix/feed-parity-jsonld-arrays
k2kirov Oct 6, 2026
1f4d259
fix(core): skip search and locator forms in autofill coverage
k2kirov Oct 6, 2026
1e8acf4
Merge pull request #100 from ForkPoint/fix/autofill-skip-locator-search
k2kirov Oct 6, 2026
fefd795
fix(core): skip empty layout sections in section-headings
k2kirov Oct 6, 2026
d486bad
Merge pull request #101 from ForkPoint/fix/section-headings-skip-empty
k2kirov Oct 6, 2026
44f55ef
fix(core): rss-feed is not applicable without a feed
k2kirov Oct 6, 2026
5ffb620
Merge pull request #102 from ForkPoint/fix/rss-feed-absent-na
k2kirov Oct 6, 2026
1a9adcf
fix(core): webmcp forms not applicable without WebMCP markup
k2kirov Oct 6, 2026
f634821
Merge pull request #103 from ForkPoint/fix/webmcp-forms-unadopted-na
k2kirov Oct 6, 2026
5e7f4bc
fix(core): scope unique-data to content pages
k2kirov Oct 6, 2026
6f04cec
Merge pull request #104 from ForkPoint/fix/unique-data-content-pages
k2kirov Oct 6, 2026
370d263
fix(core): accept cross-domain and PDF policy links in ACP link surface
k2kirov Oct 6, 2026
ad5c52a
Merge pull request #105 from ForkPoint/fix/acp-policy-links-cross-domain
k2kirov Oct 6, 2026
d37700c
fix(core): resolve the last display declaration in ghost-content scan
k2kirov Oct 6, 2026
3d35bc7
Merge pull request #106 from ForkPoint/fix/css-hidden-cascade-last-di…
k2kirov Oct 6, 2026
34a7270
docs(evidence): defer stylesheet-hidden templates in a11y audits
k2kirov Oct 6, 2026
f7b059d
Merge pull request #107 from ForkPoint/fix/a11y-skip-css-hidden-templ…
k2kirov Oct 6, 2026
d71c911
fix(core): call blast-radius origins hosts, not companies
k2kirov Oct 6, 2026
70812a4
Merge pull request #108 from ForkPoint/fix/blast-radius-first-party-cdn
k2kirov Oct 6, 2026
b54e399
fix(core): skip values, chrome and hidden text in fake-headings
k2kirov Oct 6, 2026
4c7b942
Merge pull request #110 from ForkPoint/fix/fake-headings-hidden-and-s…
k2kirov Oct 6, 2026
e929f6c
fix(core): match text fragments over nearest-block runs
k2kirov Oct 6, 2026
b51f9fc
Merge pull request #111 from ForkPoint/fix/text-fragment-hidden-and-c…
k2kirov Oct 6, 2026
a8d3cfa
fix(core): skip @id references in JSON-LD duplicate count
k2kirov Oct 6, 2026
c4774d5
Merge pull request #112 from ForkPoint/fix/jsonld-dup-skip-references
k2kirov Oct 6, 2026
766484f
fix(core): read namespaced atom:link in feed WebSub discovery
k2kirov Oct 6, 2026
19803c0
Merge pull request #113 from ForkPoint/fix/websub-namespaced-atom-link
k2kirov Oct 6, 2026
3e7906d
fix(core): skip wrappers and inner labels of native controls in ghost…
k2kirov Oct 6, 2026
484045f
Merge pull request #114 from ForkPoint/fix/ghost-wrapper-with-native-…
k2kirov Oct 6, 2026
f36b022
fix(core): compare aria-label with visible text on controls only
k2kirov Oct 6, 2026
488acd8
Merge pull request #115 from ForkPoint/fix/aria-divergence-interactiv…
k2kirov Oct 6, 2026
83fbb86
fix(core): treat FAQPage entries as editorial in UGC boundary scan
k2kirov Oct 6, 2026
24a7a5a
Merge pull request #116 from ForkPoint/fix/ugc-skip-editorial-faq
k2kirov Oct 6, 2026
a5e61e7
fix(core): decline contact-form when a contact page was not sampled
k2kirov Oct 6, 2026
d2280b1
Merge pull request #117 from ForkPoint/fix/contact-form-unsampled-na
k2kirov Oct 6, 2026
dc60dca
fix(core): skip hidden regions and keycaps in semantic-lists
k2kirov Oct 6, 2026
0203b76
Merge pull request #118 from ForkPoint/fix/semantic-lists-hidden-and-kbd
k2kirov Oct 6, 2026
ad35474
fix(core): report THIN sections without warning on them
k2kirov Oct 6, 2026
32b1cf4
Merge pull request #119 from ForkPoint/fix/section-split-thin-informa…
k2kirov Oct 6, 2026
175945b
fix(core): count title-kept h1 and skip hidden spans in survival recall
k2kirov Oct 6, 2026
f0f9b59
Merge pull request #120 from ForkPoint/fix/survival-recall-title-and-…
k2kirov Oct 6, 2026
59015a9
fix(core): let Last-Modified corroborate but not contradict lastmod
k2kirov Oct 6, 2026
766c7a8
Merge pull request #121 from ForkPoint/fix/lastmod-header-cannot-diverge
k2kirov Oct 6, 2026
5547e74
chore(release): mark the false-positive sweep as 6.0.0
k2kirov Oct 6, 2026
e6ce099
Merge pull request #122 from ForkPoint/chore/release-6-0-0
k2kirov Oct 6, 2026
e6ef300
fix(core): resolve false-positive review findings
k2kirov Oct 6, 2026
9361a70
fix(core): close gaps left by the false-positive review fixes
k2kirov Oct 6, 2026
959698e
chore: sync main for PR 124
k2kirov Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/review-css-source-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": patch
---

Preserve document order between linked stylesheets and inline style blocks when resolving CSS-hidden content.
5 changes: 5 additions & 0 deletions .changeset/review-ghost-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": patch
---

Keep missing-role findings for decorative descendants and for inline handlers that stop propagation inside controls. An observing handler, such as an analytics call, stays part of its control. Roles resolve case-insensitively to the first recognized token, and gridcell and scrollbar wrappers count as holding a control.
5 changes: 5 additions & 0 deletions .changeset/review-inline-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": patch
---

Resolve inline display and visibility declaration order and !important before hiding answer spans or content. Invalid values are dropped as a browser drops them, a visibility:visible descendant of a visibility:hidden block counts as rendered for text fragments, and CSS-hidden ghost content uses the same resolver for inline markers and stylesheet display rules.
5 changes: 5 additions & 0 deletions .changeset/review-lastmod-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": patch
---

Do not fail corroborated recent content edits as build stamps. Require page-date disagreement before reporting that pattern. A deploy-time Last-Modified header no longer vouches for a recent run of stamps; those URLs are unverifiable and warn.
5 changes: 5 additions & 0 deletions .changeset/review-unread-sitemap-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": patch
---

Decline discovery coverage findings when an index has unread child sitemaps, including an empty partial read and a child that already failed as a root.
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,26 @@ flyouts or a search panel; `stripe-com-pricing` pass → fail, whose three
FAQPage answers are only in `display:none` accordion panels. That last move
corrects a false pass.

### Inline declaration order (2026-10-06)

The shared visibility helper resolves duplicate inline `display` and
`visibility` declarations before deciding whether text is hidden. The last
declaration at the winning `!important` priority wins. Thus
`display:none;display:block` keeps an answer searchable, while an earlier
`display:none!important` still hides it from a later plain declaration.
This does not add stylesheet specificity or block-layout computation.

A declaration with an invalid value is dropped, as a browser drops it, so
`display:none;display:nonee` stays hidden. `visibility` inherits and a
descendant can set it back: an answer with `visibility:visible` inside a
`visibility:hidden` block is rendered, and find-in-page reaches it. The
nearest element that declares `visibility` decides. `visibility:collapse`
hides like `hidden`. `display:none` still hides everything under it.
Each element is judged on its own chain, so a visible answer under a hidden
wrapper stays addressable. A value with `var()` is valid when parsed and
overrides an earlier declaration; its result is unknown here, so it never
counts as hiding.

## Deferred

- The headless variant — re-running the matcher against the post-JS DOM to catch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,22 @@ never ranked against each other. A collapsed panel hidden by
counted, as the mechanism requires. An override inside a media query does not
cancel a base rule, so parallel desktop and mobile copies stay counted.

Source order is the gatherer's order: inline `<style>` blocks first, then
linked same-origin sheets. A linked sheet placed before an inline block in the
document is read after it.
Source order follows the document: inline `<style>` blocks and linked
same-origin sheets keep their relative positions. A later inline block can
override an earlier linked sheet, and the reverse order works the same way.
The five-sheet cap and the URL safety checks remain in force.

### Inline markers use the shared resolver (2026-10-06)

The inline check that skips text Readability already drops matched
`display:none` anywhere in the `style` attribute. Readability reads the
resolved style, so `display:none;display:block` is kept and its text is
ingested. The audit now resolves inline declarations with the same helper as
`dom-visibility.ts`: the last valid declaration at the winning `!important`
priority wins, and an invalid value is dropped. Stylesheet `display` rules
use the same helper, so an invalid later value no longer cancels an earlier
`display:none`. A `var()` value is valid when parsed, so it does cancel one:
`display:none; display:var(--layout, block)` is not counted as hidden.

## Deferred

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Content-page scans do not infer a mount. Origin files, origin probes, feed disco

### Partial sitemap reads decline the verdict (2026-10-06)

The shared sitemap walk keeps at most 500 entries and reads at most 10 child sitemaps. When it stops early, a page missing from the entries it kept may still be listed in the part it skipped. The audit now returns `na` for that case instead of `fail` or `warn`. A page found in the partial read still passes, and a page missing from a complete read still fails.
The shared sitemap walk keeps at most 500 entries and reads at most 10 child sitemaps. When it stops early, a page missing from the entries it kept may still be listed in the part it skipped. The audit now returns `na` for that case instead of `fail` or `warn`. Unread children of ordinary origin-level indexes also make coverage incomplete. This includes a failed fetch, malformed child, or nested index beyond the walk limit. The guard applies even when the readable portion contains no URLs. A child that already failed as a root, or under another index, still counts as unread when it is listed again. A page found in the partial read still passes, and a page missing from a complete read still fails.

The trigger was a large retail site. Its index lists a 2455-URL product sitemap before a one-URL sitemap that holds the homepage. The product file filled the cap, and the audit failed the homepage as unindexed.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Cross-validates every sampled sitemap <lastmod> against three independent page-l

## Claimed mechanism (falsifiable)

Google states it uses <lastmod> 'if it's consistently and verifiably (for example by comparing to the last modification of the page) accurate'. lastmod is therefore a conditional signal, which engines silently discard on divergence. It is also the only freshness hint a pull-based AI crawler gets from a sitemap. Falsifiable claim: if sampled lastmod values disagree with all available page-level evidence (HTTP Last-Modified, JSON-LD dateModified, article:modified_time) for a material fraction of URLs, the sitemap's freshness channel is inert and re-crawl scheduling degrades to organic rediscovery. Two pathologies are specifically detectable. First, more than 90% of URLs share one identical lastmod equal to the last deploy date. That is a build stamp rather than a content date, and per Google's 'copyright date is not significant' rule it is exactly the disqualifying pattern. Second, a lastmod in the future relative to crawl time, which is always invalid.
Google states it uses <lastmod> 'if it's consistently and verifiably (for example by comparing to the last modification of the page) accurate'. lastmod is therefore a conditional signal, which engines silently discard on divergence. It is also the only freshness hint a pull-based AI crawler gets from a sitemap. Falsifiable claim: if sampled lastmod values disagree with all available page-level evidence (HTTP Last-Modified, JSON-LD dateModified, article:modified_time) for a material fraction of URLs, the sitemap's freshness channel is inert and re-crawl scheduling degrades to organic rediscovery. A recent cluster of lastmod values is consistent with a build stamp only when page dates also disagree. A batch of genuine edits can produce the same cluster. The distribution alone proves no defect. A lastmod in the future relative to crawl time is invalid.

## Evidence

Expand All @@ -38,7 +38,7 @@ Screaming Frog, Sitebulb, Semrush and Ahrefs surface lastmod presence and can fl

## Implementation sketch

1. Fetch robots.txt Sitemap: directives plus /sitemap.xml, /sitemap_index.xml; recurse <sitemapindex> one level. 2) Validate each lastmod parses as W3C Datetime (YYYY-MM-DD or full RFC3339); count malformed. 3) Reservoir-sample 30-50 URLs across all child sitemaps. 4) For each: GET, capture the Last-Modified response header; parse all JSON-LD blocks for dateModified/datePublished; parse <meta property="article:modified_time"> and <meta name="last-modified">. 5) Per URL compute min absolute delta between sitemap lastmod and any available page signal. 6) Report: %future-dated (FAIL if >0), %malformed, distribution entropy of lastmod values (FAIL if the modal value covers >90% of sampled URLs AND that value is within 3 days of the crawl date), and %URLs whose delta exceeds 7 days against every available signal (FAIL if >20%). 7) Report separately the %URLs with no page-level signal at all — that is an actionable sub-finding (add dateModified to JSON-LD) rather than a lastmod failure.
1. Fetch robots.txt Sitemap: directives plus /sitemap.xml, /sitemap_index.xml; recurse <sitemapindex> one level. 2) Validate each lastmod parses as W3C Datetime (YYYY-MM-DD or full RFC3339); count malformed. 3) Reservoir-sample 30-50 URLs across all child sitemaps. 4) For each: GET, capture the Last-Modified response header; parse all JSON-LD blocks for dateModified/datePublished; parse <meta property="article:modified_time"> and <meta name="last-modified">. 5) Per URL compute min absolute delta between sitemap lastmod and any available page signal. 6) Report: %future-dated (FAIL if >0), %malformed, the largest cluster within a one-hour window (build-stamp context only when it covers >90% of sampled URLs, falls within 3 days of the crawl date, and the page-date divergence threshold is exceeded), and %URLs whose delta exceeds 7 days against every available signal (FAIL if >20%). 7) Report separately the %URLs with no page-level signal at all — that is an actionable sub-finding (add dateModified to JSON-LD) rather than a lastmod failure.

## Example failure

Expand All @@ -56,10 +56,10 @@ Tier per evidence policy: **scored** — grade A meets the A/B bar required for

The two audits ask different questions and must not be collapsed into one:

| Audit | Question | Fails when |
| ------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------- |
| `machine-discovery/sitemap-lastmod` | Is `lastmod` **present**? | The sitemap omits it, or omits it on most URLs. |
| `machine-discovery/sitemap-lastmod-verifiability` | Is `lastmod` **true**? | The values that exist contradict the pages, are future-dated, or are one deploy stamp repeated. |
| Audit | Question | Fails when |
| ------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `machine-discovery/sitemap-lastmod` | Is `lastmod` **present**? | The sitemap omits it, or omits it on most URLs. |
| `machine-discovery/sitemap-lastmod-verifiability` | Is `lastmod` **true**? | The values that exist contradict the pages, are future-dated, or repeat a deploy stamp that contradicts page dates. |

A sitemap can pass the first and fail this one, which is the common case: the
CMS emits `lastmod` on every URL and rewrites all of them on every build.
Expand Down Expand Up @@ -105,7 +105,7 @@ The trigger was a large retail site. Its product pages publish no modification t

### A build stamp is one run, not one string (2026-10-06)

The claim is "one identical lastmod equal to the last deploy date". The audit compared lastmod strings exactly. A generator that writes the clock per URL spreads one run over seconds, and exact matching saw every value as different. One large retail site stamped 2451 product URLs between 09:57:16 and 09:57:33 on one day. The audit now counts the largest group of sampled values inside a one-hour window. The 90% share and the 3-day recency rules are unchanged. Values hours apart still count as separate dates.
The claim is "one identical lastmod equal to the last deploy date". The audit compared lastmod strings exactly. A generator that writes the clock per URL spreads one run over seconds, and exact matching saw every value as different. One large retail site stamped 2451 product URLs between 09:57:16 and 09:57:33 on one day. The audit now counts the largest group of sampled values inside a one-hour window. The 90% share and the 3-day recency rules are unchanged. Values hours apart still count as separate dates. The correction below limits how this pattern affects the verdict.

### `Last-Modified` corroborates but cannot contradict (2026-10-06)

Expand All @@ -125,6 +125,32 @@ an article meta date is judged as before, against every signal it has.

No corpus or bare-site verdict moved: the corpus fixtures carry no sitemap.

### A date cluster needs contradictory evidence (2026-10-06)

Six real edits within 25 minutes can each agree exactly with the page's
`dateModified`. The one-hour rule previously failed that fully corroborated
sample as a build stamp. A cluster now adds build-stamp context only when
more than 20% of comparable page dates also diverge by over seven days.
A corroborated batch passes. Missing page dates keep the existing
unverifiable warning and never prove a build stamp. Future dates and
contradictory content dates still fail. This corrects the implementation
sketch against the existing source requirement: the timestamp must describe
the significant content update, not follow any required publication cadence.

### A deploy-time header cannot vouch for a stamp run (2026-10-06)

The cluster rule above left one gap. A static host can write every
`lastmod` at build time and send the deploy time as `Last-Modified`. The two
agree to within minutes, so every URL counted as corroborated and the audit
passed. That agreement shows when the build ran, not when the content
changed.

When the sample has the stamp shape — over 90% in one recent one-hour run —
the header is no longer compared. Content dates still corroborate or
contradict as before. A URL with only the header becomes unverifiable, so
the site gets the existing warning, not a pass and not a fail. Outside that
shape the header corroborates exactly as before.

## Deferred

- **Only the first level of a `<sitemapindex>` is walked**, per the shared
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,25 @@ link's text.
Two rules now hold for an element whose only signal is a name or a cursor:

- **It holds a control.** An element that contains `a[href]`, `button`,
`select`, `textarea`, `summary`, a non-hidden `input`, or any declared
`role` is layout around that control, and is not a ghost.
`select`, `textarea`, `summary`, a non-hidden `input`, or an interactive
widget role is layout around that control, and is not a ghost. Decorative
and structural roles such as `presentation`, `none`, `img` and `status`
do not supply an action.
- **It sits inside a control.** An element inside a link, a button, a
`summary` or a widget role is part of that control. A click on it is the
control's click.

An inline handler (`onclick` and the rest) is the element's own action. It
still flags the element whatever it contains or sits in.
still flags the element whatever it contains. Inside a control it counts
only when it stops the event: `stopPropagation`, `stopImmediatePropagation`
or `cancelBubble`. Any other handler, such as an analytics call, runs and
lets the click reach the control, so the element stays part of it.

Role names match as the ARIA role attribute is read: case-insensitive, and
the first token that names a concrete role wins. `role="link button"` is a
link, and `role="img button"` is an image, not a control. A wrapper around
a `gridcell` or a `scrollbar` holds a control too. `progressbar` is a widget
role but takes no input, so it is not one.

Verdicts that moved in the real-page corpus, all from wrappers and inner
labels: `barclays-co-uk-current-accounts`, `capitalone-com-savings`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ function faqJsonLd(answer: string) {

const SIMPLE = `<main><h2>What is resoling?</h2><p>${ANSWER}</p></main>`;

it("addresses a FAQ answer restored by the last inline declaration", () => {
const result = run(
`<h2>Resoling</h2><p style="display:none;display:block">${ANSWER}</p>`,
faqJsonLd(ANSWER),
);
expect(result.status).toBe("pass");
});

it.each([
`<div style="visibility:hidden"><section style="visibility:visible"><h2>Resoling</h2><p>${ANSWER}</p></section></div>`,
`<h2>Resoling</h2><div style="visibility:hidden"><p style="visibility:visible">${ANSWER}</p></div>`,
])(
"addresses a visible answer inside a visibility:hidden block: %s",
(body) => {
const result = run(body, faqJsonLd(ANSWER));
expect(result.status).toBe("pass");
},
);

describe("TextFragmentAddressabilityAudit", () => {
const audit = new TextFragmentAddressabilityAudit();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ function blockRuns(page: PageContext): BlockRun[] {
}
const tag = (node as { tagName?: string }).tagName?.toLowerCase();
if (!tag || UNRENDERED.has(tag)) return;
const own = rendered && !notRendered($, node);
// notRendered walks the ancestors itself. A visibility:visible child of
// a visibility:hidden block renders, so the parent's state cannot decide.
const own = !notRendered($, node);
const block = $(node).is(BLOCK_SELECTOR);
if (block) flush();
for (const child of (node as Element).children ?? []) walk(child, own);
Expand Down Expand Up @@ -121,13 +123,15 @@ function answerAfter(page: PageContext, heading: Element): string | undefined {
const $ = page.$;
const next = $(heading).next();
if (next.length === 0 || next.is("h1,h2,h3,h4,h5,h6")) return undefined;
if (notRendered($, next.get(0)!)) return undefined;

const leaves = (next.find(BLOCK_SELECTOR).toArray() as Element[])
.filter((el) => $(el).find(BLOCK_SELECTOR).length === 0)
.filter((el) => !notRendered($, el))
.map((el) => normalize($(el).text()))
.filter(Boolean);
// A hidden wrapper can still hold a visibility:visible leaf, so only a
// wrapper with no rendered leaf is skipped whole.
if (leaves.length === 0 && notRendered($, next.get(0)!)) return undefined;

let span = "";
if (leaves.length === 0) {
Expand Down
Loading