diff --git a/src/defuddle.ts b/src/defuddle.ts index 9f2236f96..57345e1af 100644 --- a/src/defuddle.ts +++ b/src/defuddle.ts @@ -19,7 +19,7 @@ import { removeBySelector } from './removals/selectors'; import { removeByContentPattern, removeEyebrowLabel } from './removals/content-patterns'; import { removeMetadataBlock } from './removals/metadata-block'; import { getComputedStyle, textPreview, countWords } from './utils'; -import { parseHTML, serializeHTML, decodeHTMLEntities, isDangerousUrl, getClassName } from './utils/dom'; +import { parseHTML, serializeHTML, decodeHTMLEntities, isDangerousUrl, getClassName, escapeCssIdent } from './utils/dom'; interface StyleChange { selector: string; @@ -29,10 +29,11 @@ interface StyleChange { /** Keys from extractor variables that map to top-level DefuddleResponse fields */ const STANDARD_VARIABLE_KEYS = new Set(['title', 'author', 'published', 'site', 'description', 'image', 'language']); -// CSS-special characters that make class names invalid in selectors (Tailwind utilities like sm:pt-[131px]) +// Classes carrying CSS syntax (Tailwind utilities like sm:pt-[131px]) are dropped +// from generated selectors rather than escaped. Unlike ids, this is a deliberate +// trade: escaping them would change which elements long-standing selectors match. const UNSAFE_CSS_CLASS_RE = /[:\[\]()#>~+,]/; - export class Defuddle { // Reassigned briefly during the schema.org fallback so re-extraction runs // against a sanitized clone instead of the caller's live document. @@ -47,6 +48,10 @@ export class Defuddle { private _smallImages: Set | undefined; private _inExtractorPipelineRun = false; + // Results produced by the whole- fallback rather than by real extraction. + // Tracked out-of-band so the marker never reaches callers via DefuddleResponse. + private _degradedResults = new WeakSet(); + /** * Create a new Defuddle instance * @param doc - The document to parse @@ -85,7 +90,7 @@ export class Defuddle { let result = this.parseInternal(); // If result has very little content, try again without clutter removal - if (result.wordCount < 200) { + if (this._effectiveWordCount(result) < 200) { this._log('Initial parse returned very little content, trying again'); const retryResult = this.parseInternal({ removePartialSelectors: false @@ -95,7 +100,7 @@ export class Defuddle { // A small increase likely means partial selectors correctly removed // clutter (author blocks, related articles, etc.) from a short article. // A large increase (2x+) suggests partial selectors were too aggressive. - if (retryResult.wordCount > result.wordCount * 2) { + if (this._preferCandidate(result, retryResult, () => retryResult.wordCount > result.wordCount * 2)) { this._log('Retry produced more content'); result = retryResult; } @@ -104,12 +109,12 @@ export class Defuddle { // If still very little content, the page may be an index/listing page // or a page that reveals content at runtime from a hidden wrapper. // Retry once with hidden-element removal disabled. - if (result.wordCount < 50) { + if (this._effectiveWordCount(result) < 50) { this._log('Still very little content, retrying without hidden-element removal'); const hiddenRetry = this.parseInternal({ removeHiddenElements: false }); - if (hiddenRetry.wordCount > result.wordCount * 2) { + if (this._preferCandidate(result, hiddenRetry, () => hiddenRetry.wordCount > result.wordCount * 2)) { this._log('Hidden-element retry produced more content'); result = hiddenRetry; } @@ -124,13 +129,13 @@ export class Defuddle { removePartialSelectors: false, contentSelector: hiddenSelector }); - if ( + if (this._preferCandidate(result, hiddenSelectorRetry, () => hiddenSelectorRetry.wordCount > result.wordCount || ( hiddenSelectorRetry.wordCount > Math.max(20, result.wordCount * 0.7) && hiddenSelectorRetry.content.length < result.content.length ) - ) { + )) { this._log('Hidden-selector retry produced better focused content'); result = hiddenSelectorRetry; } @@ -140,14 +145,14 @@ export class Defuddle { // If still very little content, the page may be an index/listing page // where card elements were scored as non-content or removed by partial // selectors (e.g. "post-preview"). Retry with both disabled. - if (result.wordCount < 50) { + if (this._effectiveWordCount(result) < 50) { this._log('Still very little content, retrying without scoring/partial selectors (possible index page)'); const indexRetry = this.parseInternal({ removeLowScoring: false, removePartialSelectors: false, removeContentPatterns: false }); - if (indexRetry.wordCount > result.wordCount) { + if (this._preferCandidate(result, indexRetry, () => indexRetry.wordCount > result.wordCount)) { this._log('Index page retry produced more content'); result = indexRetry; } @@ -158,7 +163,7 @@ export class Defuddle { // Use a 1.5x threshold to avoid triggering when the difference is small // (e.g. just related-content link text removed). const schemaText = this._getSchemaText(result.schemaOrgData); - if (schemaText && this.countHtmlWords(schemaText) > result.wordCount * 1.5) { + if (schemaText && this.countHtmlWords(schemaText) > this._effectiveWordCount(result) * 1.5) { // Re-extract from a sanitized clone so dangerous elements and URI // attributes (e.g. data:text/html in an img src) in the matched // element are stripped, without mutating the caller's live document. @@ -176,11 +181,18 @@ export class Defuddle { const selector = this.getElementSelector(bestMatch); this._log('Schema.org suggests a better content element, retrying with selector:', selector); const schemaRetry = this.parseInternal({ contentSelector: selector }); - result = schemaRetry; + // Trusted over the current result — schema.org named this element as + // the article body — but a degraded retry is still a whole-page dump + // and must not replace a real extraction. + if (this._preferCandidate(result, schemaRetry, () => true)) { + result = schemaRetry; + } } else { this._log('Using schema.org text as content (DOM element not found)'); result.content = schemaText; result.wordCount = this.countHtmlWords(schemaText); + // No longer a whole- dump: content now comes from schema.org. + this._degradedResults.delete(result); } } finally { this.doc = liveDoc; @@ -227,6 +239,53 @@ export class Defuddle { * extraction pipeline already removes script/style/etc. via EXACT_SELECTORS, * so only these raw-body paths need to sanitize here. */ + /** + * Build the response for a path that failed to extract anything and is falling + * back to the whole . Marked degraded: its word count reflects the entire + * page (nav, footer, sidebars) and would otherwise beat every real extraction + * in parse()'s retry comparisons, and suppress the gates that trigger them. + */ + private _degradedResponse(startTime: number): DefuddleResponse { + const content = this._serializeFallbackBody(); + const response: DefuddleResponse = { + content, + ...this._metadata, + wordCount: this.countHtmlWords(content), + parseTime: Math.round(Date.now() - startTime), + metaTags: this._metaTags + }; + this._degradedResults.add(response); + return response; + } + + /** True if the result came from the whole- fallback, not real extraction. */ + private _isDegraded(result: DefuddleResponse): boolean { + return this._degradedResults.has(result); + } + + /** + * Word count for retry-gating purposes. A degraded result counts as zero so its + * inflated whole-page count can't suppress the retries that would find content. + */ + private _effectiveWordCount(result: DefuddleResponse): number { + return this._isDegraded(result) ? 0 : result.wordCount; + } + + /** + * Whether a retry candidate should replace the current result. Degraded results + * never win; any real extraction beats a degraded current. Otherwise defer to + * the caller's predicate, which differs per retry. + */ + private _preferCandidate( + current: DefuddleResponse, + candidate: DefuddleResponse, + isBetter: () => boolean + ): boolean { + if (this._isDegraded(candidate)) return false; + if (this._isDegraded(current)) return true; + return isBetter(); + } + private _serializeFallbackBody(): string { if (!this.doc.body) return ''; const safeBody = this.doc.body.cloneNode(true) as HTMLElement; @@ -832,7 +891,7 @@ export class Defuddle { removeHiddenElements: false, }); const variables = this.getExtractorVariables(extracted.variables); - return { + const merged: DefuddleResponse = { ...pipelineResult, title: extracted.variables?.title || pipelineResult.title, description: extracted.variables?.description || pipelineResult.description, @@ -843,6 +902,11 @@ export class Defuddle { extractorType: extractor.constructor.name.replace('Extractor', '').toLowerCase(), ...(variables ? { variables } : {}), }; + // Spreading creates a new identity, so carry the marker across. + if (this._isDegraded(pipelineResult)) { + this._degradedResults.add(merged); + } + return merged; } finally { this._inExtractorPipelineRun = false; } @@ -887,7 +951,13 @@ export class Defuddle { const mainContent = profileStep('findMainContent', (): Element | null => { let found: Element | null = null; if (options.contentSelector) { - found = clone.querySelector(options.contentSelector); + // contentSelector is public API input and may be unparseable. + // Degrade to auto-detection rather than failing the whole parse. + try { + found = clone.querySelector(options.contentSelector); + } catch (e) { + this._log('Invalid contentSelector, falling back to auto-detection:', options.contentSelector, e); + } this._log('Using contentSelector:', options.contentSelector, found ? 'found' : 'not found'); } if (!found) { @@ -919,15 +989,7 @@ export class Defuddle { }); if (!mainContent) { - const fallbackContent = this._serializeFallbackBody(); - const endTime = Date.now(); - return { - content: fallbackContent, - ...metadata, - wordCount: this.countHtmlWords(fallbackContent), - parseTime: Math.round(endTime - startTime), - metaTags: pageMetaTags - }; + return this._degradedResponse(startTime); } // Remove h1-adjacent date/author metadata blocks from the content. @@ -1065,15 +1127,7 @@ export class Defuddle { return result; } catch (error) { console.error('Defuddle', 'Error processing document:', error); - const errorContent = this._serializeFallbackBody(); - const endTime = Date.now(); - return { - content: errorContent, - ...metadata, - wordCount: this.countHtmlWords(errorContent), - parseTime: Math.round(endTime - startTime), - metaTags: pageMetaTags - }; + return this._degradedResponse(startTime); } } @@ -1338,8 +1392,12 @@ export class Defuddle { while (current && current !== this.doc.documentElement) { let selector = current.tagName.toLowerCase(); if (current.id) { - selector += '#' + current.id; + // Escaping an id is lossless — it still matches the same one element. + selector += '#' + escapeCssIdent(current.id); } else if (getClassName(current)) { + // Dropping a class is lossy by comparison: the selector widens, and if + // every class is unsafe this degrades to a bare tag name. See the note + // on UNSAFE_CSS_CLASS_RE for why that trade is kept. const safe = getClassName(current).trim().split(/\s+/) .filter(cls => !UNSAFE_CSS_CLASS_RE.test(cls)); if (safe.length) { diff --git a/src/elements/footnotes.ts b/src/elements/footnotes.ts index 6c32be2ff..2f85d74f3 100644 --- a/src/elements/footnotes.ts +++ b/src/elements/footnotes.ts @@ -402,8 +402,12 @@ class FootnoteHandler { const contentDiv = element.ownerDocument.createElement('div'); const clone = el.cloneNode(true); - // Remove empty/numeric ID anchors (e.g. or 1.) - const idAnchor = clone.querySelector(`a[id="${id}"]`); + // Remove empty/numeric ID anchors (e.g. or 1.). + // The id comes from the page, so it is compared in JS rather than + // interpolated into a selector — a quote or backslash in it would make the + // selector unparseable and throw. Matches the a[name] handling just below. + const idAnchor = Array.from(clone.querySelectorAll('a[id]')) + .find((a: any) => a.getAttribute('id') === id) as any; if (idAnchor && (!idAnchor.textContent?.trim() || /^\d+[.)]*\s*$/.test(idAnchor.textContent.trim()))) { idAnchor.remove(); } diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 47df398c4..218b09c03 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -43,6 +43,40 @@ export function escapeHtml(text: string): string { .replace(/"/g, '"'); } +// Characters valid in a CSS identifier as-is: letters, digits, `_`, `-`, non-ASCII +const CSS_IDENT_SAFE_RE = /[\w\u0080-\uffff-]/; + +/** + * Escape a string for use as a CSS identifier, so values taken from a page + * can't turn a generated selector into invalid CSS. React streaming SSR is the + * common trigger: it emits ids like `S:a` and `B:0`, and an unescaped `#S:a` + * parses as a pseudo-class. + * + * Deliberately not delegating to `CSS.escape` where it exists: linkedom has no + * `CSS` global, so the Node/CLI/Worker environments need this anyway, and + * always using it keeps selectors identical across every environment. + * NUL is the one case this drops versus `CSS.escape` — the HTML tokenizer + * replaces it with U+FFFD before it can reach an attribute value. + */ +export function escapeCssIdent(value: string): string { + let result = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + // Control characters, and a digit leading the identifier (or following a + // leading hyphen), have no inline `\x` form and need the hex escape. + const leadingDigit = ch >= '0' && ch <= '9' + && (i === 0 || (i === 1 && value[0] === '-')); + if (leadingDigit || ch <= '\x1f' || ch === '\x7f') { + result += '\\' + ch.charCodeAt(0).toString(16) + ' '; + } else if (ch === '-' && value.length === 1) { + result += '\\-'; + } else { + result += CSS_IDENT_SAFE_RE.test(ch) ? ch : '\\' + ch; + } + } + return result; +} + /** * Safely get an element's class name as a string. * Handles SVG elements where className is an SVGAnimatedString. diff --git a/tests/css-ident-escaping.test.ts b/tests/css-ident-escaping.test.ts new file mode 100644 index 000000000..4f05e0355 --- /dev/null +++ b/tests/css-ident-escaping.test.ts @@ -0,0 +1,159 @@ +import { describe, test, expect } from 'vitest'; +import Defuddle from '../src/index'; +import { escapeCssIdent } from '../src/utils/dom'; +import { parseDocument } from './helpers'; + +/** + * escapeCssIdent() exists because getElementSelector() feeds its output back into + * querySelector(), and also hands it to callers as result.debug.contentSelector. + * An id carrying CSS syntax (React streaming SSR emits id="S:a", id="B:0") would + * otherwise produce a selector that throws when re-parsed. + * + * The helper deliberately implements the full CSSOM CSS.escape algorithm rather + * than a shorter approximation, so these tests pin the edge cases that a + * "simplification" would most plausibly drop — a leading digit, a digit after a + * leading hyphen, and a lone hyphen all need the hex-escape form, and none of + * them can be expressed with an inline backslash escape. + */ + +/** + * Canonical CSS.escape (CSSOM spec algorithm), used as an independent oracle. + * Kept verbatim rather than simplified: its whole job is to disagree with the + * implementation if the implementation drifts. + */ +function cssEscapeReference(value: string): string { + const string = String(value); + const length = string.length; + const firstCodeUnit = string.charCodeAt(0); + let index = -1; + let result = ''; + while (++index < length) { + const codeUnit = string.charCodeAt(index); + if (codeUnit === 0x0000) { + result += '�'; + continue; + } + if ( + (codeUnit >= 0x0001 && codeUnit <= 0x001f) || codeUnit === 0x007f || + (index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d) + ) { + result += '\\' + codeUnit.toString(16) + ' '; + continue; + } + if (index === 0 && length === 1 && codeUnit === 0x002d) { + result += '\\' + string.charAt(index); + continue; + } + if ( + codeUnit >= 0x0080 || codeUnit === 0x002d || codeUnit === 0x005f || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || + (codeUnit >= 0x0061 && codeUnit <= 0x007a) + ) { + result += string.charAt(index); + continue; + } + result += '\\' + string.charAt(index); + } + return result; +} + +// Real-world shapes plus every CSS metacharacter and the ident-grammar edges. +const IDS = [ + // React streaming SSR — the shapes that motivated the helper + 'S:a', 'B:0', 'P:8', + // Ordinary ids that must pass through untouched + 'plain', 'foo-bar_baz', 'a1', '__next', 'post-body-1234', '--custom-prop', + // Ident-grammar edges needing the hex form + '1abc', '2col', '-1abc', '-', + // CSS metacharacters + 'a b', 'a.b', 'a#b', 'a[b]', 'a(b)', 'a>b', 'a~b', 'a+b', 'a,b', 'a*b', + 'a"b', "a'b", 'a\\b', 'a/b', 'a%b', 'a@b', 'a!b', 'a=b', 'a|b', 'a^b', 'a$b', + // Control character, and non-ASCII which stays literal + 'a\tb', 'café', '日本語', 'Ω', +]; + +describe('escapeCssIdent', () => { + test('escapes the ident-grammar edge cases', () => { + // A leading digit has no inline escape form — it needs `\3N ` + expect(escapeCssIdent('1abc')).toBe('\\31 abc'); + // ...including a digit following a leading hyphen + expect(escapeCssIdent('-1abc')).toBe('-\\31 abc'); + // A lone hyphen is not a valid ident on its own + expect(escapeCssIdent('-')).toBe('\\-'); + // But a double hyphen is fine + expect(escapeCssIdent('--custom-prop')).toBe('--custom-prop'); + }); + + test('escapes CSS syntax and leaves safe characters alone', () => { + expect(escapeCssIdent('S:a')).toBe('S\\:a'); + expect(escapeCssIdent('B:0')).toBe('B\\:0'); + expect(escapeCssIdent('a[b]')).toBe('a\\[b\\]'); + expect(escapeCssIdent('foo-bar_baz')).toBe('foo-bar_baz'); + expect(escapeCssIdent('café')).toBe('café'); + expect(escapeCssIdent('')).toBe(''); + }); + + test('agrees with the CSSOM CSS.escape algorithm', () => { + for (const id of IDS) { + expect(escapeCssIdent(id), `mismatch for ${JSON.stringify(id)}`) + .toBe(cssEscapeReference(id)); + } + }); + + // jsdom's selector engine (nwsapi) fails to match the three escapes that emit a + // backslash before a quote or backslash — \" \' \\ — inside an ID selector, + // though it accepts their equivalent hex forms and real browsers accept both. + // The escaping is spec-correct (the CSS.escape agreement test above covers these + // ids), so this is a limitation of that engine rather than of the helper. + // linkedom, which backs the Node/CLI/Worker paths, matches all of them. + const roundTripIds = process.env.DOM === 'jsdom' + ? IDS.filter(id => !/["'\\]/.test(id)) + : IDS; + + test('every escaped id parses and matches the right element', () => { + for (const id of roundTripIds) { + const attr = id.replace(/&/g, '&').replace(/"/g, '"'); + const doc = parseDocument( + `
decoy
target
`, + 'https://example.com/' + ); + const selector = 'div#' + escapeCssIdent(id); + let found: Element | null = null; + expect( + () => { found = doc.querySelector(selector); }, + `selector threw for id=${JSON.stringify(id)} selector=${selector}` + ).not.toThrow(); + expect(found, `no match for id=${JSON.stringify(id)} selector=${selector}`).not.toBeNull(); + expect( + (found as unknown as Element).textContent, + `matched the wrong element for id=${JSON.stringify(id)}` + ).toBe('target'); + } + }); +}); + +describe('generated selectors round-trip', () => { + // debug.contentSelector is public output that callers paste back in as the + // contentSelector option, so it has to be a selector querySelector accepts. + test('debug.contentSelector is usable against the source document', () => { + const para = '

This is a substantial paragraph of genuine article body text that a ' + + 'content extractor should identify as the main content rather than boilerplate.

'; + const html = `T + + +

Copyright 2026 Example Inc.

+ `; + + const doc = parseDocument(html, 'https://example.com/'); + const result = new Defuddle(doc, { url: 'https://example.com/', debug: true }).parse(); + const selector = result.debug?.contentSelector; + + expect(selector).toBeTruthy(); + expect(selector).toContain('\\:'); + const fresh = parseDocument(html, 'https://example.com/'); + expect(() => fresh.querySelector(selector!)).not.toThrow(); + expect(fresh.querySelector(selector!)).not.toBeNull(); + }); +}); diff --git a/tests/degraded-fallback.test.ts b/tests/degraded-fallback.test.ts new file mode 100644 index 000000000..9a2cd954b --- /dev/null +++ b/tests/degraded-fallback.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { parseDocument } from './helpers'; + +/** + * When any pipeline step throws, parseInternal() catches it and returns the whole + * via the fallback path. That result carries a word count for the entire + * page — nav, sidebars, footer — which is typically far larger than a real + * extraction of the same page. + * + * Two things must not happen as a result: + * 1. its inflated count must not satisfy the retry gates, skipping the retries + * that would have found real content + * 2. it must not win a retry comparison against a real extraction + * + * These tests force a throw in standardizeContent on the first parse only, so the + * first attempt degrades and the retry succeeds. + */ + +const { throwOnNextCall } = vi.hoisted(() => ({ throwOnNextCall: { value: false } })); + +vi.mock('../src/standardize', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + standardizeContent: (...args: Parameters) => { + if (throwOnNextCall.value) { + throwOnNextCall.value = false; + throw new Error('simulated pipeline failure'); + } + return actual.standardizeContent(...args); + } + }; +}); + +const PARA = '

This is a substantial paragraph of genuine article body text that a content ' + + 'extractor should confidently identify as the main content of this page rather than ' + + 'as surrounding navigation or boilerplate chrome.

'; + +const HTML = `Test Article + +

Test Article

${PARA.repeat(8)}
+

Copyright 2026 Example Inc. All rights reserved. Privacy Policy. Terms of Service.

+`; + +async function parseWithFirstParseThrowing() { + const { Defuddle } = await import('../src/node'); + const doc = parseDocument(HTML, 'https://example.com/'); + throwOnNextCall.value = true; + const res = await Defuddle(doc, 'https://example.com/'); + throwOnNextCall.value = false; + return res; +} + +describe('degraded whole- fallback', () => { + beforeEach(() => { + throwOnNextCall.value = false; + vi.restoreAllMocks(); + }); + + test('a throwing first parse still retries and returns real content', async () => { + const res = await parseWithFirstParseThrowing(); + + // The retry succeeded, so boilerplate outside the content div must be gone. + expect(res.content).not.toContain('Copyright 2026'); + expect(res.content).not.toContain('Gamma'); + expect(res.content).toContain('substantial paragraph'); + }); + + test('control: without a throw, output is identical', async () => { + const { Defuddle } = await import('../src/node'); + const doc = parseDocument(HTML, 'https://example.com/'); + const clean = await Defuddle(doc, 'https://example.com/'); + const degradedThenRetried = await parseWithFirstParseThrowing(); + + expect(degradedThenRetried.content).toEqual(clean.content); + }); +}); diff --git a/tests/expected/issues--footnote-id-with-quote.md b/tests/expected/issues--footnote-id-with-quote.md new file mode 100644 index 000000000..ce6afe150 --- /dev/null +++ b/tests/expected/issues--footnote-id-with-quote.md @@ -0,0 +1,22 @@ +```json +{ + "title": "Counting Methods - Example Journal", + "author": "", + "site": "", + "published": "" +} +``` + +## Counting Methods + +Counting anything at scale sounds like it should be a simple matter of consulting one authoritative list, but the number you arrive at depends almost entirely on what you decide counts in the first place. + +The strictest definitions include only certified entries that meet every published criterion.[^1] By that measure the total is comparatively small, because most records in any given registry never meet the full bar. + +A broader count adds provisional entries, which are recorded but not yet certified.[^2] These vastly outnumber the certified ones, and many consist of little more than a name and a date. + +This is why two sources can both be correct while disagreeing wildly. One may be reporting certified entries while the other reports every record, and neither is wrong so much as answering a different question than the reader assumed. + +[^1]: First cited source, Example Press, 2026. + +[^2]: Second cited source, Example Press, 2026. \ No newline at end of file diff --git a/tests/expected/issues--react-streaming-ssr-colon-id.md b/tests/expected/issues--react-streaming-ssr-colon-id.md new file mode 100644 index 000000000..077c0cd73 --- /dev/null +++ b/tests/expected/issues--react-streaming-ssr-colon-id.md @@ -0,0 +1,20 @@ +```json +{ + "title": "How Many Airports Are There?", + "author": "", + "site": "Example Travel", + "published": "" +} +``` + +Counting airports sounds like it should be a simple matter of consulting a single authoritative list, but the number you arrive at depends almost entirely on what you decide counts as an airport in the first place. + +The strictest definitions include only certified facilities that handle scheduled commercial passenger service. By that measure the total is comparatively small, because most of the landing places in any given country never see a scheduled flight at all. + +A broader count adds general aviation fields, which serve private pilots, flight schools, and charter operators. These vastly outnumber commercial airports, and many of them consist of little more than a paved strip, a windsock, and a small parking area. + +Broader still are registries that include every recorded landing area: grass strips on private farmland, gravel runways serving remote communities, heliports on hospital rooftops, and seaplane bases on lakes and rivers. Counted this way, the total can climb by an order of magnitude. + +This is why two sources can both be correct while disagreeing wildly. One may be reporting certified commercial airports while the other reports every registered aerodrome, and neither is wrong so much as answering a different question than the reader assumed. + +The practical lesson for anyone comparing these figures is to check the definition before comparing the numbers. A citation without its underlying criteria tells you very little about the aviation infrastructure it claims to describe. \ No newline at end of file diff --git a/tests/fixtures/issues--footnote-id-with-quote.html b/tests/fixtures/issues--footnote-id-with-quote.html new file mode 100644 index 000000000..a008216c3 --- /dev/null +++ b/tests/fixtures/issues--footnote-id-with-quote.html @@ -0,0 +1,31 @@ + + + + + + Counting Methods - Example Journal + + +
+

Counting Methods

+ +

Counting anything at scale sounds like it should be a simple matter of consulting one authoritative list, but the number you arrive at depends almost entirely on what you decide counts in the first place.

+ +

The strictest definitions include only certified entries that meet every published criterion.1 By that measure the total is comparatively small, because most records in any given registry never meet the full bar.

+ +

A broader count adds provisional entries, which are recorded but not yet certified.2 These vastly outnumber the certified ones, and many consist of little more than a name and a date.

+ +

This is why two sources can both be correct while disagreeing wildly. One may be reporting certified entries while the other reports every record, and neither is wrong so much as answering a different question than the reader assumed.

+ + +
    +
  1. First cited source, Example Press, 2026.
  2. +
  3. Second cited source, Example Press, 2026.
  4. +
+
+ + diff --git a/tests/fixtures/issues--react-streaming-ssr-colon-id.html b/tests/fixtures/issues--react-streaming-ssr-colon-id.html new file mode 100644 index 000000000..9011bbd7d --- /dev/null +++ b/tests/fixtures/issues--react-streaming-ssr-colon-id.html @@ -0,0 +1,45 @@ + + + + + + How Many Airports Are There? - Example Travel + + + + + + + + + + + + + + +