compareSeoTrees function

List<SeoFinding> compareSeoTrees({
  1. required String path,
  2. required List<SeoNode> ssr,
  3. required List<SeoNode> app,
  4. SeoParityPolicy policy = const SeoParityPolicy(),
})

Compares the server-rendered ssr body against the app mirror.

path only labels the findings.

Implementation

List<SeoFinding> compareSeoTrees({
  required String path,
  required List<SeoNode> ssr,
  required List<SeoNode> app,
  SeoParityPolicy policy = const SeoParityPolicy(),
}) {
  final findings = <SeoFinding>[];
  final ssrFacts = SeoBodyFacts.of(ssr);
  final appFacts = SeoBodyFacts.of(app);

  if (ssrFacts.truncated || appFacts.truncated) {
    // A truncated walk cannot support ANY verdict: with the deep half
    // of one tree unseen, "this text reaches only crawlers" would be a
    // confident error about content nobody compared. Report the
    // truncation and stop — the same reasoning that skips cross-page
    // checks when the page set is partial.
    findings.add(SeoFinding(
      check: SeoCheck.bodyTruncated,
      severity: SeoSeverity.error,
      path: path,
      message: 'a tree nests deeper than the comparison walks '
          '(${SeoBodyFacts.maxDepth} levels) — parity on this page '
          'cannot be judged and was not',
    ));
    return findings;
  }

  if (policy.compareHeadings) {
    final ssrH1 = _headings(ssrFacts, 1)
        .where((heading) => !_ignored(heading, policy))
        .toList();
    final appH1 = _headings(appFacts, 1)
        .where((heading) => !_ignored(heading, policy))
        .toList();

    // Ask whether the server's headline appears in the app at all, not
    // whether it comes first. Comparing first-to-first failed on two
    // perfectly ordinary shapes: an app shell whose untagged brand text
    // becomes an <h1> through smart defaults, and Flutter keeping an
    // inactive Navigator route mounted after a push, so the previous
    // page's <h1> is still in the tree. Neither means the page content
    // disagrees; an extra heading is already reported separately, as a
    // warning.
    // Guarding on `appH1.isNotEmpty` let the sharpest case through: an
    // app that renders the headline as a <p> has the same words and no
    // <h1> at all, so the heading check skipped and the word check saw
    // nothing missing. The page really does differ — the server
    // promises a heading the app never delivers.
    if (ssrH1.isNotEmpty && !appH1.contains(ssrH1.first)) {
      findings.add(SeoFinding(
        check: SeoCheck.parityH1Differs,
        severity: SeoSeverity.error,
        path: path,
        message: appH1.isEmpty
            ? 'the server sends an <h1> and the app renders none — the '
                'text may be there, but not as a heading'
            : 'the server\'s <h1> appears nowhere in the app — crawlers '
                'and visitors are reading different pages',
        detail: 'server "${ssrH1.first}", app has '
            '${appH1.isEmpty ? 'no <h1>' : appH1.map((h) => '"$h"').join(', ')}',
      ));
    }

    // A heading the app shows but the server does not means the route
    // body has gone stale — the usual way this happens is someone
    // adding a section to the widget and forgetting the table.
    for (final heading in appFacts.headings) {
      if (_ignored(heading.text, policy)) continue;
      final match = ssrFacts.headings
          .any((h) => _same(h.text, heading.text) && h.level == heading.level);
      if (!match) {
        findings.add(SeoFinding(
          check: SeoCheck.parityAppOnlyHeading,
          severity: SeoSeverity.warning,
          path: path,
          message: 'the app shows a heading the server body does not — '
              'the route body has probably gone stale',
          detail: 'h${heading.level} "${heading.text}"',
        ));
      }
    }
  }

  if (policy.compareText) {
    // The cloaking direction, and the serious one: text sent only to
    // crawlers. The app's words are concatenated in tree order first,
    // then each SSR text run must occur as one contiguous token sequence.
    // A passage split across adjacent spans therefore still matches, while
    // repeated pairs scattered across navigation and other paragraphs do not.
    final appSequence = [
      for (final run in appFacts.textRuns)
        if (!_ignored(run, policy)) ..._tokens(run),
    ];
    final appWords = appSequence.toSet();
    final claimed = List<bool>.filled(appSequence.length, false);
    final missing = <String>[];
    final reordered = <String>[];
    for (final run in ssrFacts.textRuns) {
      if (_ignored(run, policy)) continue;
      final words = _tokens(run);
      if (words.isEmpty) continue;
      if (_claimSequence(appSequence, words, claimed)) {
        continue;
      }
      // The passage exists, but only in a range already used by an earlier
      // SSR passage. One app occurrence cannot account for two copies sent
      // to crawlers; that is a real multiplicity mismatch, not reordering.
      if (_containsSequence(appSequence, words)) {
        missing.add(run);
        continue;
      }
      // Graded: words absent means the content is gone — the cloaking
      // direction, an error. All words present but not adjacent is
      // usually a layout artifact — a Row of Columns interleaves label
      // and value runs in tree order, which is not reading order — and
      // failing the build over that teaches teams to turn the check
      // off. A warning keeps it visible without lying about severity.
      (words.every(appWords.contains) ? reordered : missing).add(run);
    }
    if (missing.isNotEmpty) {
      findings.add(SeoFinding(
        check: SeoCheck.paritySsrOnlyText,
        severity: SeoSeverity.error,
        path: path,
        message: '${missing.length} passage(s) go to crawlers but never '
            'appear in the app — this is cloaking, however accidental',
        detail: missing.take(3).map((t) => '"${_clip(t)}"').join(', '),
      ));
    }
    if (reordered.isNotEmpty) {
      findings.add(SeoFinding(
        check: SeoCheck.paritySsrOnlyText,
        severity: SeoSeverity.warning,
        path: path,
        message: '${reordered.length} passage(s) reach the app only as '
            'scattered words, not as the passage — usually a layout '
            'artifact (columns, tables), worth a look either way',
        detail: reordered.take(3).map((t) => '"${_clip(t)}"').join(', '),
      ));
    }
  }

  if (policy.compareLinks) {
    final appHrefs = {
      for (final link in appFacts.links) link.attributes['href'] ?? '',
    };
    for (final link in ssrFacts.links) {
      final href = link.attributes['href'] ?? '';
      if (href.isEmpty || appHrefs.contains(href)) continue;
      findings.add(SeoFinding(
        check: SeoCheck.parityLinkMissingInApp,
        severity: SeoSeverity.info,
        path: path,
        message: 'a link exists in the server body but not in the app',
        detail: href,
      ));
    }
  }

  return findings;
}