From c7d9e5fefb9791646c2add35555839b882b5216a Mon Sep 17 00:00:00 2001 From: Chlod Alejandro Date: Fri, 7 Jun 2024 23:32:16 +0800 Subject: [PATCH] support new headers (T13555) Still TODO: Refactor all uses of `ContributionSurveyHeading` to use WikiHeading instead of HTMLHeadingElement. This will avoid unnecessary repeated calls of normalizeWikiHeading(). --- .../ia/models/CopyrightProblemsListing.ts | 16 +- .../ia/models/CopyrightProblemsSession.ts | 71 +++--- src/session/DeputyRootSession.ts | 56 ++--- src/ui/root/DeputyCCISessionStartLink.tsx | 10 +- .../root/DeputyContributionSurveySection.tsx | 34 ++- src/wiki/DeputyCasePage.ts | 61 +---- src/wiki/util/findSectionHeading.ts | 8 +- src/wiki/util/getWikiHeadingLevel.ts | 23 -- src/wiki/util/index.ts | 8 +- src/wiki/util/isWikiHeading.ts | 7 +- src/wiki/util/normalizeWikiHeading.ts | 232 ++++++++++++++++++ src/wiki/util/sectionHeadingId.ts | 16 -- src/wiki/util/sectionHeadingN.ts | 9 +- src/wiki/util/sectionHeadingName.ts | 22 -- tests/unit/browser/DeputyCasePageUnitTests.ts | 2 +- 15 files changed, 354 insertions(+), 221 deletions(-) delete mode 100644 src/wiki/util/getWikiHeadingLevel.ts create mode 100644 src/wiki/util/normalizeWikiHeading.ts delete mode 100644 src/wiki/util/sectionHeadingId.ts delete mode 100644 src/wiki/util/sectionHeadingName.ts diff --git a/src/modules/ia/models/CopyrightProblemsListing.ts b/src/modules/ia/models/CopyrightProblemsListing.ts index abd637a7a..b0c6f8aa7 100644 --- a/src/modules/ia/models/CopyrightProblemsListing.ts +++ b/src/modules/ia/models/CopyrightProblemsListing.ts @@ -6,6 +6,7 @@ import decorateEditSummary from '../../../wiki/util/decorateEditSummary'; import MwApi from '../../../MwApi'; import changeTag from '../../../config/changeTag'; import warn from '../../../util/warn'; +import normalizeWikiHeading from '../../../wiki/util/normalizeWikiHeading'; export interface SerializedCopyrightProblemsListingData { basic: boolean; @@ -113,7 +114,14 @@ export default class CopyrightProblemsListing { el.parentElement.tagName === 'LI' ? el.parentElement.parentElement : el.parentElement ).previousElementSibling; - while ( previousPivot != null && previousPivot.tagName !== 'H4' ) { + let heading; + // Search for a level 4 heading backwards. + while ( + previousPivot != null && + // Set the ceiling to be immediately above for efficiency. + ( heading = normalizeWikiHeading( previousPivot, previousPivot.parentElement ) ) + ?.level !== 4 + ) { previousPivot = previousPivot.previousElementSibling; } @@ -121,9 +129,9 @@ export default class CopyrightProblemsListing { return false; } - if ( previousPivot.querySelector( '.mw-headline' ) != null ) { - // At this point, previousPivot is likely a MediaWiki level 4 heading. - const h4Anchor = previousPivot.querySelector( '.mw-headline a' ); + // At this point, previousPivot is likely a MediaWiki level 4 heading. + const h4Anchor = heading.h.querySelector( 'a' ); + if ( h4Anchor ) { listingPage = pagelinkToTitle( h4Anchor as HTMLAnchorElement ); // Identify if the page is a proper listing page (within the root page's diff --git a/src/modules/ia/models/CopyrightProblemsSession.ts b/src/modules/ia/models/CopyrightProblemsSession.ts index 55c9cda65..b65bf948a 100644 --- a/src/modules/ia/models/CopyrightProblemsSession.ts +++ b/src/modules/ia/models/CopyrightProblemsSession.ts @@ -7,6 +7,7 @@ import equalTitle from '../../../util/equalTitle'; import swapElements from '../../../util/swapElements'; import NewCopyrightProblemsListing from '../ui/NewCopyrightProblemsListing'; import normalizeTitle from '../../../wiki/util/normalizeTitle'; +import normalizeWikiHeading from '../../../wiki/util/normalizeWikiHeading'; /** * A CopyrightProblemsPage that represents a page that currently exists on a document. @@ -116,11 +117,14 @@ export default class CopyrightProblemsSession extends CopyrightProblemsPage { } /** - * + * Adds a panel containing the "new listing" buttons (single and multiple) + * and the panel container (when filing a multiple-page listing) to the proper + * location: either at the end of the copyright problems section or replacing + * the redlink to the blank copyright problems page. */ addNewListingsPanel(): void { document.querySelectorAll( - '.mw-headline > a, a.external, a.redlink' + '.mw-headline a, .mw-heading a, a.external, a.redlink' ).forEach( ( el ) => { const href = el.getAttribute( 'href' ); const url = new URL( href, window.location.href ); @@ -133,61 +137,54 @@ export default class CopyrightProblemsSession extends CopyrightProblemsPage { CopyrightProblemsPage.getCurrentListingPage().getPrefixedText() ) ) { - // Crawl backwards, avoiding common inline elements, to see if this is a standalone - // line within the rendered text. - let currentPivot: Element = el.parentElement; - - while ( - currentPivot !== null && - [ 'I', 'B', 'SPAN', 'EM', 'STRONG' ].indexOf( currentPivot.tagName ) !== -1 - ) { - currentPivot = currentPivot.parentElement; - } - - // By this point, current pivot will be a
,

, or other usable element. - if ( - !el.parentElement.classList.contains( 'mw-headline' ) && - ( currentPivot == null || - currentPivot.children.length > 1 ) - ) { - return; - } else if ( el.parentElement.classList.contains( 'mw-headline' ) ) { - // "Edit source" button of an existing section heading. - let headingBottom = el.parentElement.parentElement.nextElementSibling; - let pos: InsertPosition = 'beforebegin'; + if ( el.classList.contains( 'external' ) || el.classList.contains( 'redlink' ) ) { + // Keep crawling up and find the parent of this element that is directly + // below the parser root or the current section. + let currentPivot = el; while ( - headingBottom != null && - !/^H[123456]$/.test( headingBottom.tagName ) + currentPivot != null && + !currentPivot.classList.contains( 'mw-parser-output' ) && + [ 'A', 'I', 'B', 'SPAN', 'EM', 'STRONG' ] + .indexOf( currentPivot.tagName ) !== -1 ) { - headingBottom = headingBottom.nextElementSibling; + currentPivot = currentPivot.parentElement; } - if ( headingBottom == null ) { - headingBottom = el.parentElement.parentElement.parentElement; - pos = 'beforeend'; + // We're now at the

or

or whatever. + // Check if it only has one child (the tree that contains this element) + // and if so, replace the links. + + if ( currentPivot.children.length > 1 ) { + return; } - // Add below today's section header. mw.loader.using( [ 'oojs-ui-core', 'oojs-ui.styles.icons-interactions', 'mediawiki.widgets', 'mediawiki.widgets.TitlesMultiselectWidget' ], () => { - // H4 - headingBottom.insertAdjacentElement( - pos, - NewCopyrightProblemsListing() - ); + swapElements( currentPivot, NewCopyrightProblemsListing() ); } ); } else { + // This is in a heading. Let's place it after the section heading. + const heading = normalizeWikiHeading( el ); + + if ( heading.root.classList.contains( 'dp-ia-upgraded' ) ) { + return; + } + heading.root.classList.add( 'dp-ia-upgraded' ); + mw.loader.using( [ 'oojs-ui-core', 'oojs-ui.styles.icons-interactions', 'mediawiki.widgets', 'mediawiki.widgets.TitlesMultiselectWidget' ], () => { - swapElements( el, NewCopyrightProblemsListing() ); + heading.root.insertAdjacentElement( + 'afterend', + NewCopyrightProblemsListing() + ); } ); } } diff --git a/src/session/DeputyRootSession.ts b/src/session/DeputyRootSession.ts index c866452a6..463d39e28 100644 --- a/src/session/DeputyRootSession.ts +++ b/src/session/DeputyRootSession.ts @@ -2,7 +2,6 @@ import DeputyCasePage, { ContributionSurveyHeading } from '../wiki/DeputyCasePag import DeputyCCISessionStartLink from '../ui/root/DeputyCCISessionStartLink'; import removeElement from '../util/removeElement'; import unwrapWidget from '../util/unwrapWidget'; -import sectionHeadingName from '../wiki/util/sectionHeadingName'; import { DeputyMessageEvent, DeputySessionRequestMessage, @@ -13,9 +12,9 @@ import DeputyContributionSurveySection from '../ui/root/DeputyContributionSurvey import { SessionInformation } from './DeputySession'; import { ArrayOrNot } from '../types'; import DeputyMessageWidget from '../ui/shared/DeputyMessageWidget'; -import sectionHeadingId from '../wiki/util/sectionHeadingId'; import last from '../util/last'; import findNextSiblingElement from '../util/findNextSiblingElement'; +import normalizeWikiHeading from '../wiki/util/normalizeWikiHeading'; /** * The DeputyRootSession. Instantiated only when: @@ -43,9 +42,10 @@ export default class DeputyRootSession { casePage.findContributionSurveyHeadings() .forEach( ( heading: ContributionSurveyHeading ) => { - const link = DeputyCCISessionStartLink( heading, casePage ); + const normalizedHeading = normalizeWikiHeading( heading ); + const link = DeputyCCISessionStartLink( normalizedHeading, casePage ); startLink.push( link as HTMLElement ); - heading.appendChild( link ); + normalizedHeading.root.appendChild( link ); } ); window.deputy.comms.addEventListener( 'sessionStarted', () => { @@ -68,7 +68,7 @@ export default class DeputyRootSession { await mw.loader.using( [ 'oojs-ui-core', 'oojs-ui.styles.icons-content' ], () => { - const firstHeading = casePage.findFirstContributionSurveyHeading(); + const firstHeading = casePage.findFirstContributionSurveyHeadingElement(); if ( firstHeading ) { const stopButton = new OO.ui.ButtonWidget( { label: mw.msg( 'deputy.session.otherActive.button' ), @@ -108,9 +108,9 @@ export default class DeputyRootSession { window.deputy.session.init(); } ); - casePage.normalizeSectionHeading( + normalizeWikiHeading( firstHeading - ).insertAdjacentElement( + ).root.insertAdjacentElement( 'beforebegin', unwrapWidget( messageBox ) ); @@ -136,8 +136,8 @@ export default class DeputyRootSession { const lastActiveSection = DeputyRootSession.findFirstLastActiveSection( casePage ); const firstSection = - casePage.normalizeSectionHeading( - casePage.findFirstContributionSurveyHeading() + normalizeWikiHeading( + casePage.findFirstContributionSurveyHeadingElement() ); // Insert element directly into widget (not as text, or else event @@ -165,10 +165,10 @@ export default class DeputyRootSession { 'deputy.session.continue.help' : 'deputy.session.continue.help.fromStart', lastActiveSection ? - sectionHeadingName( lastActiveSection ) : + normalizeWikiHeading( lastActiveSection ).title : casePage.lastActiveSections[ 0 ] .replace( /_/g, ' ' ), - sectionHeadingName( firstSection ) + firstSection.title ), actions: [ continueButton ], closable: true @@ -183,7 +183,7 @@ export default class DeputyRootSession { DeputyRootSession.continueSession( casePage ); } else { DeputyRootSession.continueSession( casePage, [ - sectionHeadingId( firstSection ) + firstSection.id ] ); } window.deputy.comms.removeEventListener( @@ -192,7 +192,7 @@ export default class DeputyRootSession { ); } ); - firstSection.insertAdjacentElement( + firstSection.root.insertAdjacentElement( 'beforebegin', unwrapWidget( messageBox ) ); @@ -221,7 +221,7 @@ export default class DeputyRootSession { [ 'oojs-ui-core', 'oojs-ui.styles.icons-content' ], () => { const firstHeading = - casePage.findFirstContributionSurveyHeading(); + casePage.findFirstContributionSurveyHeadingElement(); if ( firstHeading ) { const messageBox = DeputyMessageWidget( { classes: [ @@ -232,9 +232,9 @@ export default class DeputyRootSession { message: mw.msg( 'deputy.session.tabActive.help' ), closable: true } ); - casePage.normalizeSectionHeading( + normalizeWikiHeading( firstHeading - ).insertAdjacentElement( + ).root.insertAdjacentElement( 'beforebegin', unwrapWidget( messageBox ) ); @@ -264,7 +264,7 @@ export default class DeputyRootSession { const csHeadings = casePage.findContributionSurveyHeadings(); for ( const lastActiveSection of casePage.lastActiveSections ) { for ( const heading of csHeadings ) { - if ( sectionHeadingId( heading ) === lastActiveSection ) { + if ( normalizeWikiHeading( heading ).id === lastActiveSection ) { return heading; } } @@ -284,7 +284,7 @@ export default class DeputyRootSession { _casePage?: DeputyCasePage ): Promise { const sectionIds = ( Array.isArray( section ) ? section : [ section ] ).map( - ( _section ) => sectionHeadingId( _section ) + ( _section ) => normalizeWikiHeading( _section ).id ); // Save session to storage const casePage = _casePage ?? await DeputyCasePage.build(); @@ -438,7 +438,7 @@ export default class DeputyRootSession { const activeSectionPromises = []; for ( const heading of this.casePage.findContributionSurveyHeadings() ) { - const headingId = sectionHeadingId( heading ); + const headingId = normalizeWikiHeading( heading ).id; if ( this.session.caseSections.indexOf( headingId ) !== -1 ) { activeSectionPromises.push( @@ -509,8 +509,8 @@ export default class DeputyRootSession { * @param heading */ addSectionOverlay( casePage: DeputyCasePage, heading: ContributionSurveyHeading ): void { - const normalizedHeading = casePage.normalizeSectionHeading( heading ); - const section = casePage.getContributionSurveySection( normalizedHeading ); + const normalizedHeading = normalizeWikiHeading( heading ).root; + const section = casePage.getContributionSurveySection( normalizedHeading as HTMLElement ); const list = section.find( ( v ) => v instanceof HTMLElement && v.tagName === 'UL' ) as HTMLUListElement; @@ -582,7 +582,7 @@ export default class DeputyRootSession { return false; } - const sectionId = sectionHeadingId( heading ); + const sectionId = normalizeWikiHeading( heading ).id; this.sections.push( el ); const lastActiveSession = this.session.caseSections.indexOf( sectionId ); if ( lastActiveSession === -1 ) { @@ -591,11 +591,7 @@ export default class DeputyRootSession { } await casePage.addActiveSection( sectionId ); - if ( heading.parentElement.classList.contains( 'mw-heading' ) ) { - heading.parentElement.insertAdjacentElement( 'afterend', el.render() ); - } else { - heading.insertAdjacentElement( 'afterend', el.render() ); - } + normalizeWikiHeading( heading ).root.insertAdjacentElement( 'afterend', el.render() ); await el.loadData(); mw.hook( 'deputy.load.cci.session' ).fire(); @@ -628,9 +624,9 @@ export default class DeputyRootSession { const casePage = e0 instanceof DeputyContributionSurveySection ? e0.casePage : e0; const heading = e0 instanceof DeputyContributionSurveySection ? - e0.heading : e1; + e0.heading : normalizeWikiHeading( e1 ); - const sectionId = sectionHeadingId( heading ); + const sectionId = heading.id; const sectionListIndex = this.sections.indexOf( el ); if ( el != null && sectionListIndex !== -1 ) { this.sections.splice( sectionListIndex, 1 ); @@ -647,7 +643,7 @@ export default class DeputyRootSession { } else { await DeputyRootSession.setSession( this.session ); await casePage.removeActiveSection( sectionId ); - this.addSectionOverlay( casePage, heading ); + this.addSectionOverlay( casePage, heading.h ); } } } diff --git a/src/ui/root/DeputyCCISessionStartLink.tsx b/src/ui/root/DeputyCCISessionStartLink.tsx index 1d886144b..9eab7275c 100644 --- a/src/ui/root/DeputyCCISessionStartLink.tsx +++ b/src/ui/root/DeputyCCISessionStartLink.tsx @@ -1,6 +1,6 @@ import { h } from 'tsx-dom'; -import DeputyCasePage, { ContributionSurveyHeading } from '../../wiki/DeputyCasePage'; -import sectionHeadingId from '../../wiki/util/sectionHeadingId'; +import DeputyCasePage from '../../wiki/DeputyCasePage'; +import { WikiHeading } from '../../wiki/util/normalizeWikiHeading'; /** * The CCI session start link. Starts a CCI session when pressed. @@ -10,14 +10,14 @@ import sectionHeadingId from '../../wiki/util/sectionHeadingId'; * @return The link element to be displayed */ export default function ( - heading: ContributionSurveyHeading, + heading: WikiHeading, casePage?: DeputyCasePage ): JSX.Element { return [ { if ( casePage && casePage.lastActiveSections.length > 0 ) { - const headingId = sectionHeadingId( heading ); + const headingId = heading.id; if ( window.deputy.config.cci.openOldOnContinue.get() ) { if ( casePage.lastActiveSections.indexOf( headingId ) === -1 ) { await casePage.addActiveSection( headingId ); @@ -29,7 +29,7 @@ export default function ( ); } } else { - await window.deputy.session.DeputyRootSession.startSession( heading ); + await window.deputy.session.DeputyRootSession.startSession( heading.h ); } } }>{ mw.message( diff --git a/src/ui/root/DeputyContributionSurveySection.tsx b/src/ui/root/DeputyContributionSurveySection.tsx index f4da8d088..a26c17fec 100644 --- a/src/ui/root/DeputyContributionSurveySection.tsx +++ b/src/ui/root/DeputyContributionSurveySection.tsx @@ -6,7 +6,6 @@ import DeputyContributionSurveyRow from './DeputyContributionSurveyRow'; import ContributionSurveyRow from '../../models/ContributionSurveyRow'; import ContributionSurveySection from '../../models/ContributionSurveySection'; import DeputyReviewDialog from './DeputyReviewDialog'; -import sectionHeadingName from '../../wiki/util/sectionHeadingName'; import getSectionId from '../../wiki/util/getSectionId'; import getSectionHTML from '../../wiki/util/getSectionHTML'; import removeElement from '../../util/removeElement'; @@ -25,6 +24,7 @@ import error from '../../util/error'; import DeputyExtraneousElement from './DeputyExtraneousElement'; import classMix from '../../util/classMix'; import dangerModeConfirm from '../../util/dangerModeConfirm'; +import normalizeWikiHeading, { WikiHeading } from '../../wiki/util/normalizeWikiHeading'; /** * The contribution survey section UI element. This includes a list of revisions @@ -38,7 +38,7 @@ export default class DeputyContributionSurveySection implements DeputyUIElement casePage: DeputyCasePage; private _section: ContributionSurveySection; - heading: HTMLHeadingElement; + heading: WikiHeading; sectionNodes: Node[]; originalList: HTMLElement; /** @@ -261,14 +261,14 @@ export default class DeputyContributionSurveySection implements DeputyUIElement * @return the name of the section heading. */ get headingName(): string { - return sectionHeadingName( this.heading ); + return this.heading.title; } /** * @return the `n` of the section heading, if applicable. */ get headingN(): number { - return sectionHeadingN( this.heading, this.headingName ); + return sectionHeadingN( this.heading ); } /** @@ -279,7 +279,7 @@ export default class DeputyContributionSurveySection implements DeputyUIElement */ constructor( casePage: DeputyCasePage, heading: ContributionSurveyHeading ) { this.casePage = casePage; - this.heading = casePage.normalizeSectionHeading( heading ); + this.heading = normalizeWikiHeading( heading ); this.sectionNodes = casePage.getContributionSurveySection( heading ); } @@ -467,10 +467,10 @@ export default class DeputyContributionSurveySection implements DeputyUIElement * @param toggle */ toggleSectionElements( toggle: boolean ) { - const bottom: Node = this.heading.nextSibling ?? null; + const bottom: Node = this.heading.root.nextSibling ?? null; for ( const sectionElement of this.sectionNodes ) { if ( toggle ) { - this.heading.parentNode.insertBefore( sectionElement, bottom ); + this.heading.root.parentNode.insertBefore( sectionElement, bottom ); } else { removeElement( sectionElement ); } @@ -686,13 +686,15 @@ export default class DeputyContributionSurveySection implements DeputyUIElement // Remove whatever section elements are still there. // They may have been greatly modified by the save. const sectionElements = - this.casePage.getContributionSurveySection( this.heading ); + this.casePage.getContributionSurveySection( + this.heading.root as HTMLElement + ); sectionElements.forEach( ( el ) => removeElement( el ) ); // Clear out section elements and re-append new ones to the DOM. this.sectionNodes = []; // Heading is preserved to avoid messing with IDs. - const heading = this.heading; + const heading = this.heading.root; const insertRef = heading.nextSibling ?? null; for ( const child of Array.from( element.childNodes ) ) { if ( !this.casePage.isContributionSurveyHeading( child ) ) { @@ -707,17 +709,9 @@ export default class DeputyContributionSurveySection implements DeputyUIElement this._section = null; await this.getSection( Object.assign( wikitext, { revid } ) ); await this.prepare(); - if ( heading.parentElement.classList.contains( 'mw-heading' ) ) { - // Intentional recursive call - heading.parentElement.insertAdjacentElement( - 'afterend', this.render() - ); - } else { - // Intentional recursive call - heading.insertAdjacentElement( - 'afterend', this.render() - ); - } + heading.insertAdjacentElement( + 'afterend', this.render() + ); // Run this asynchronously. setTimeout( this.loadData.bind( this ), 0 ); } else { diff --git a/src/wiki/DeputyCasePage.ts b/src/wiki/DeputyCasePage.ts index 88e53aab5..cf96276b0 100644 --- a/src/wiki/DeputyCasePage.ts +++ b/src/wiki/DeputyCasePage.ts @@ -1,11 +1,8 @@ import DeputyCasePageWikitext from './DeputyCasePageWikitext'; -import sectionHeadingName from './util/sectionHeadingName'; import getPageTitle from './util/getPageTitle'; import DeputyCase from './DeputyCase'; -import sectionHeadingId from './util/sectionHeadingId'; -import isWikiHeading from './util/isWikiHeading'; -import getWikiHeadingLevel from './util/getWikiHeadingLevel'; import getSectionElements from './util/getSectionElements'; +import normalizeWikiHeading from './util/normalizeWikiHeading'; export type ContributionSurveyHeading = HTMLHeadingElement; @@ -144,17 +141,13 @@ export default class DeputyCasePage extends DeputyCase { return false; } - // All headings (h1, h2, h3, h4, h5, h6) - const headlineElement = this.parsoid ? - el : - el.querySelector( '.mw-headline' ); - // Handle DiscussionTools case (.mw-heading) - return isWikiHeading( el ) && - headlineElement != null && + const heading = normalizeWikiHeading( el ); + return heading != null && + el === heading.h && // eslint-disable-next-line security/detect-non-literal-regexp new RegExp( window.deputy.wikiConfig.cci.headingMatch.get() - ).test( headlineElement.innerText ); + ).test( heading.title ); } /** @@ -163,7 +156,7 @@ export default class DeputyCasePage extends DeputyCase { * * @return The element of the heading. */ - findFirstContributionSurveyHeading(): ContributionSurveyHeading { + findFirstContributionSurveyHeadingElement(): ContributionSurveyHeading { return this.findContributionSurveyHeadings()[ 0 ]; } @@ -179,14 +172,12 @@ export default class DeputyCasePage extends DeputyCase { sectionIdentifier: string, useId = false ): ContributionSurveyHeading { - // No need to perform .mw-headline existence check here, already - // done by `findContributionSurveyHeadings` return this.findContributionSurveyHeadings() .find( ( v ) => - useId ? - sectionHeadingId( v ) === sectionIdentifier : - sectionHeadingName( v ) === sectionIdentifier + normalizeWikiHeading( v )[ + useId ? 'id' : 'title' + ] === sectionIdentifier ); } @@ -211,30 +202,6 @@ export default class DeputyCasePage extends DeputyCase { } } - /** - * Normalizes a section heading. On some pages, DiscussionTools wraps the heading - * around in a div, which breaks some assumptions with the DOM tree (e.g. that the - * heading is immediately followed by section elements). - * - * This returns the element at the "root" level, i.e. the wrapping
when - * DiscussionTools is active, or the

when it is not. - * @param heading - */ - normalizeSectionHeading( heading: HTMLElement ): ContributionSurveyHeading { - if ( !this.isContributionSurveyHeading( heading ) ) { - if ( !this.isContributionSurveyHeading( heading.parentElement ) ) { - throw new Error( 'Provided section heading is not a valid section heading.' ); - } else { - heading = heading.parentElement; - } - } - // When DiscussionTools is being used, the header is wrapped in a div. - if ( heading.parentElement.classList.contains( 'mw-heading' ) ) { - heading = heading.parentElement; - } - return heading as ContributionSurveyHeading; - } - /** * Gets all elements that are part of a contribution survey "section", that is * a set of elements including the section heading and all elements succeeding @@ -251,15 +218,13 @@ export default class DeputyCasePage extends DeputyCase { * @return An array of all HTMLElements covered by the section */ getContributionSurveySection( sectionHeading: HTMLElement ): Node[] { - // Normalize "sectionHeading" to use the h* element and not the .mw-heading span. - sectionHeading = this.normalizeSectionHeading( sectionHeading ); - const sectionHeadingLevel = getWikiHeadingLevel( sectionHeading ); + const heading = normalizeWikiHeading( sectionHeading ); + const ceiling = heading.root.parentElement; return getSectionElements( - this.normalizeSectionHeading( sectionHeading ), + heading.root as HTMLElement, ( el ) => - isWikiHeading( el ) && - sectionHeadingLevel >= getWikiHeadingLevel( el ) + heading.level >= ( normalizeWikiHeading( el, ceiling )?.level ?? Infinity ) ); } diff --git a/src/wiki/util/findSectionHeading.ts b/src/wiki/util/findSectionHeading.ts index 105accef6..35b140e89 100644 --- a/src/wiki/util/findSectionHeading.ts +++ b/src/wiki/util/findSectionHeading.ts @@ -11,7 +11,13 @@ export default function findSectionHeading( ): HTMLElement | null { let currentN = 1; - const headlines = Array.from( document.querySelectorAll( 'h2 > .mw-headline' ) ); + const headlines = Array.from( document.querySelectorAll( + // Old style headings + [ 1, 2, 3, 4, 5, 6 ].map( v => `h${v} > .mw-headline` ).join( ',' ) + + ',' + + // New style headings + [ 1, 2, 3, 4, 5, 6 ].map( v => `mw-heading > h${v}` ).join( ',' ) + ) ); for ( const el of headlines ) { if ( el instanceof HTMLElement && el.innerText === sectionHeadingName ) { if ( currentN >= n ) { diff --git a/src/wiki/util/getWikiHeadingLevel.ts b/src/wiki/util/getWikiHeadingLevel.ts deleted file mode 100644 index eb38014c5..000000000 --- a/src/wiki/util/getWikiHeadingLevel.ts +++ /dev/null @@ -1,23 +0,0 @@ -import last from '../../util/last'; - -/** - * Get the level (1 to 6) of any parsed wikitext heading. - * - * This is its own function to account for different parse outputs (Legacy, Parsoid, - * DiscussionTools, etc.) - * - * @param el The element to check. This MUST be a wikitext heading. - * @return The level of the heading (1 to 6) - */ -export default function getWikiHeadingLevel( el: Element ) { - const h = el.classList.contains( 'mw-heading' ) ? - el.querySelector( 'h1, h2, h3, h4, h5, h6' ) : - el; - - // Check if this is a valid header - if ( !/^H\d+$/.test( h.tagName ) ) { - throw new Error( 'Heading element does not contain a valid element' ); - } - - return +last( h.tagName ) as 1 | 2 | 3 | 4 | 5 | 6; -} diff --git a/src/wiki/util/index.ts b/src/wiki/util/index.ts index 8e691543b..271e2dab3 100644 --- a/src/wiki/util/index.ts +++ b/src/wiki/util/index.ts @@ -13,11 +13,11 @@ import getRevisionURL from './getRevisionURL'; import getSectionElements from './getSectionElements'; import getSectionHTML from './getSectionHTML'; import getSectionId from './getSectionId'; -import getWikiHeadingLevel from './getWikiHeadingLevel'; import guessAuthor from './guessAuthor'; import isWikiHeading from './isWikiHeading'; import msgEval from './msgEval'; import normalizeTitle from './normalizeTitle'; +import normalizeWikiHeading from './normalizeWikiHeading'; import nsId from './nsId'; import openWindow from './openWindow'; import pagelinkToTitle from './pagelinkToTitle'; @@ -25,9 +25,7 @@ import parseDiffUrl from './parseDiffUrl'; import performHacks from './performHacks'; import purge from './purge'; import renderWikitext from './renderWikitext'; -import sectionHeadingId from './sectionHeadingId'; import sectionHeadingN from './sectionHeadingN'; -import sectionHeadingName from './sectionHeadingName'; import toRedirectsObject from './toRedirectsObject'; export default { decorateEditSummary: decorateEditSummary, @@ -45,11 +43,11 @@ export default { getSectionElements: getSectionElements, getSectionHTML: getSectionHTML, getSectionId: getSectionId, - getWikiHeadingLevel: getWikiHeadingLevel, guessAuthor: guessAuthor, isWikiHeading: isWikiHeading, msgEval: msgEval, normalizeTitle: normalizeTitle, + normalizeWikiHeading: normalizeWikiHeading, nsId: nsId, openWindow: openWindow, pagelinkToTitle: pagelinkToTitle, @@ -57,8 +55,6 @@ export default { performHacks: performHacks, purge: purge, renderWikitext: renderWikitext, - sectionHeadingId: sectionHeadingId, sectionHeadingN: sectionHeadingN, - sectionHeadingName: sectionHeadingName, toRedirectsObject: toRedirectsObject }; diff --git a/src/wiki/util/isWikiHeading.ts b/src/wiki/util/isWikiHeading.ts index 869568492..a4265cae8 100644 --- a/src/wiki/util/isWikiHeading.ts +++ b/src/wiki/util/isWikiHeading.ts @@ -1,12 +1,13 @@ +import normalizeWikiHeading from './normalizeWikiHeading'; + /** * Check if a given parameter is a wikitext heading parsed into HTML. * - * This is its own function to account for different parse outputs (Legacy, Parsoid, - * DiscussionTools, etc.) + * Alias for `normalizeWikiHeading( el ) != null`. * * @param el The element to check * @return `true` if the element is a heading, `false` otherwise */ export default function isWikiHeading( el: Element ): boolean { - return ( el.classList.contains( 'mw-heading' ) || /^H\d$/.test( el.tagName ) ); + return normalizeWikiHeading( el ) != null; } diff --git a/src/wiki/util/normalizeWikiHeading.ts b/src/wiki/util/normalizeWikiHeading.ts new file mode 100644 index 000000000..e3f29564e --- /dev/null +++ b/src/wiki/util/normalizeWikiHeading.ts @@ -0,0 +1,232 @@ +import last from '../../util/last'; + +/** + * Each WikiHeadingType implies specific fields in {@link WikiHeading}: + * + * - `PARSOID` implies that there is no headline element, and that the `h` + * element is the root heading element. This means `h.innerText` will be + * "Section title". + * - `OLD` implies that there is a headline element and possibly an editsection + * element, and that the `h` is the root heading element. This means that + * `h.innerText` will be "Section title[edit | edit source]" or similar. + * - `NEW` implies that there is a headline element and possibly an editsection + * element, and that a `div` is the root heading element. This means that + * `h.innerText` will be "Section title". + */ +export enum WikiHeadingType { + PARSOID, + OLD, + NEW +} + +/** + * A parsed wikitext heading. + */ +export interface WikiHeading { + /** + * The type of this heading. + */ + type: WikiHeadingType, + /** + * The root element of this heading. This refers to the topmost element + * related to this heading, excluding the `
` which contains it. + */ + root: Element, + /** + * The `` element of this heading. + */ + h: HTMLHeadingElement, + /** + * The ID of this heading. Also known as the fragment. On Parsoid, this + * is the `html5` fragment mode (mostly Unicode characters). + */ + id: string, + /** + * The title of this heading. This is the actual text that the reader can + * see, and the actual text that matters. Unsupported syntax, such as using + * `` templates in the heading, may not work. + */ + title: string, + /** + * The level of this heading. Goes from 1 to 6, referring to h1 to h6. + */ + level: number +} + +/** + * Get relevant information from an H* element in a section heading. + * + * @param headingElement The heading element + * @return An object containing the relevant {@link WikiHeading} fields. + */ +function getHeadingElementInfo( headingElement: HTMLHeadingElement ): + Pick { + return { + h: headingElement, + id: headingElement.id, + title: headingElement.innerText, + level: +last( headingElement.tagName ) + }; +} + +/** + * Annoyingly, there are many different ways that a heading can be parsed + * into depending on the version and the parser used for given wikitext. + * + * In order to properly perform such wiki heading checks, we need to identify + * if a given element is part of a wiki heading, and perform a normalization + * if so. + * + * Since this function needs to check many things before deciding if a given + * HTML element is part of a section heading or not, this also acts as an + * `isWikiHeading` check. + * + * The layout for a heading differs depending on the MediaWiki version: + * + * On 1.43+ (Parser) + * ```html + *
+ *

Parsed wikitext...

+ * Parsed wikitext...

+ * ``` + * + * On pre-1.43 + * ```html + *

+ * Parsed wikitext... + * ... + *

+ * ``` + * + * Worst case execution time would be if this was run with an element which was + * outside a heading and deeply nested within the page. + * + * Backwards-compatibility support may be removed in the future. This function does not + * support Parsoid specification versions lower than 2.0. + * + * @param node The node to check for + * @param ceiling An element which `node` must be in to be a valid heading. + * This is set to the `.mw-parser-output` element by default. + * @return The root heading element (can be an <h2> or <div>), + * or `null` if it is not a valid heading. + */ +export default function normalizeWikiHeading( node: Node, ceiling?: Element ): WikiHeading | null { + if ( node == null ) { + // Not valid input, obviously. + return null; + } + + const rootNode = node.getRootNode(); + + // Break out of text nodes until we hit an element node. + while ( node.nodeType !== node.ELEMENT_NODE ) { + node = node.parentNode; + + if ( node === rootNode ) { + // We've gone too far and hit the root. This is not a wiki heading. + return null; + } + } + + // node is now surely an element. + let elementNode = node as Element; + + // If this node is the 1.43+ heading root, return it immediately. + if ( elementNode.classList.contains( 'mw-heading' ) ) { + return { + type: WikiHeadingType.NEW, + root: elementNode, + ...getHeadingElementInfo( + Array.from( elementNode.children ) + .find( v =>/^H[123456]$/.test( v.tagName ) ) as HTMLHeadingElement + ) + }; + } + + // Otherwise, we're either inside or outside a mw-heading. + // To determine if we are inside or outside, we keep climbing up until + // we either hit an or a given stop point. + // The stop point is, by default, `.mw-parser-output`, which exists both in a + // Parsoid document and in standard parser output. If such an element doesn't + // exist in this document, we just stop at the root element. + ceiling = ceiling ?? + elementNode.ownerDocument.querySelector( '.mw-parser-output' ) ?? + elementNode.ownerDocument.documentElement; + + // While we haven't hit a heading, keep going up. + while ( elementNode !== ceiling ) { + if ( /^H[123456]$/.test( elementNode.tagName ) ) { + // This element is a heading! + // Now determine if this is a MediaWiki heading. + + if ( elementNode.parentElement.classList.contains( 'mw-heading' ) ) { + // This element's parent is a `div.mw-heading`! + return { + type: WikiHeadingType.NEW, + root: elementNode.parentElement, + ...getHeadingElementInfo( elementNode as HTMLHeadingElement ) + }; + } else { + const headline: HTMLElement = elementNode.querySelector( ':scope > .mw-headline' ); + if ( headline != null ) { + // This element has a `.mw-headline` child! + return { + type: WikiHeadingType.OLD, + root: elementNode, + h: elementNode as HTMLHeadingElement, + id: headline.id, + title: headline.innerText, + level: +last( elementNode.tagName ) + }; + } else if ( + elementNode.parentElement.tagName === 'SECTION' && + elementNode.parentElement.firstElementChild === elementNode + ) { + // A
element is directly above this element, and it is the + // first element of that section! + // This is a specific format followed by the 2.8.0 MediaWiki Parsoid spec. + // https://www.mediawiki.org/wiki/Specs/HTML/2.8.0#Headings_and_Sections + return { + type: WikiHeadingType.PARSOID, + root: elementNode, + h: elementNode as HTMLHeadingElement, + id: elementNode.id, + title: ( elementNode as HTMLElement ).innerText, + level: +last( elementNode.tagName ) + }; + } else { + // This is a heading, but we can't figure out how it works. + // This usually means something inserted an

into the DOM, and we + // accidentally picked it up. + // In that case, discard it. + return null; + } + } + } else if ( elementNode.classList.contains( 'mw-heading' ) ) { + // This element is the `div.mw-heading`! + // This usually happens when we selected an element from inside the + // `span.mw-editsection` span. + return { + type: WikiHeadingType.NEW, + root: elementNode, + ...getHeadingElementInfo( + Array.from( elementNode.children ) + .find( v =>/^H[123456]$/.test( v.tagName ) ) as HTMLHeadingElement + ) + }; + } else { + // Haven't reached the top part of a heading yet, or we are not + // in a heading. Keep climbing up the tree until we hit the ceiling. + elementNode = elementNode.parentElement; + } + } + + // We hit the ceiling. This is not a wiki heading. + return null; +} diff --git a/src/wiki/util/sectionHeadingId.ts b/src/wiki/util/sectionHeadingId.ts deleted file mode 100644 index e424b288c..000000000 --- a/src/wiki/util/sectionHeadingId.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ContributionSurveyHeading } from '../DeputyCasePage'; -import error from '../../util/error'; - -/** - * @param element The element to get the name of - * @return the ID of the section heading. - */ -export default function sectionHeadingId( element: ContributionSurveyHeading ): string { - try { - return element.querySelector( '.mw-headline' ) - .getAttribute( 'id' ); - } catch ( e ) { - error( 'Error getting section heading ID', e, element ); - throw e; - } -} diff --git a/src/wiki/util/sectionHeadingN.ts b/src/wiki/util/sectionHeadingN.ts index 59e3c097a..ab5eff72a 100644 --- a/src/wiki/util/sectionHeadingN.ts +++ b/src/wiki/util/sectionHeadingN.ts @@ -1,6 +1,6 @@ import last from '../../util/last'; -import sectionHeadingId from './sectionHeadingId'; import error from '../../util/error'; +import { WikiHeading } from './normalizeWikiHeading'; /** * Checks the n of a given element, that is to say the `n`th occurrence of a section @@ -19,18 +19,17 @@ import error from '../../util/error'; * - Otherwise, the n is the last number on the ID if the ID than the heading name. * * @param heading The heading to check - * @param headingName The name of the heading to check * @return The n, a number */ -export default function ( heading: HTMLHeadingElement, headingName: string ): number { +export default function sectionHeadingN( heading: WikiHeading ): number { try { const headingNameEndPattern = /(?:\s|_)(\d+)/g; const headingIdEndPattern = /_(\d+)/g; - const headingId = sectionHeadingId( heading ); + const headingId = heading.id; const headingIdMatches = headingId.match( headingIdEndPattern ); - const headingNameMatches = headingName.match( headingNameEndPattern ); + const headingNameMatches = heading.title.match( headingNameEndPattern ); if ( headingIdMatches == null ) { return 1; diff --git a/src/wiki/util/sectionHeadingName.ts b/src/wiki/util/sectionHeadingName.ts deleted file mode 100644 index ec94579f8..000000000 --- a/src/wiki/util/sectionHeadingName.ts +++ /dev/null @@ -1,22 +0,0 @@ -import error from '../../util/error'; - -/** - * @param element The element to get the name of - * @return the name of a section from its section heading. - */ -export default function sectionHeadingName( element: HTMLHeadingElement ): string { - try { - // Get only the direct text of .mw-headline - // Why? Because DiscussionTools inserts a [subscribe] link in the .mw-headline - // element, which we don't want to include in the section name. - const headlineElement = element.querySelector( '.mw-headline' ); - const headlineDirectText = Array.from( headlineElement?.childNodes ?? [] ) - .filter( n => n.nodeType === Node.TEXT_NODE ) - .reduce( ( acc, n ) => acc + n.textContent, '' ) - .trim(); - return headlineDirectText || headlineElement?.innerText; - } catch ( e ) { - error( 'Error getting section name', e, element ); - throw e; - } -} diff --git a/tests/unit/browser/DeputyCasePageUnitTests.ts b/tests/unit/browser/DeputyCasePageUnitTests.ts index 26768265a..2a593d0b7 100644 --- a/tests/unit/browser/DeputyCasePageUnitTests.ts +++ b/tests/unit/browser/DeputyCasePageUnitTests.ts @@ -224,7 +224,7 @@ describe( 'DeputyCasePage implementation unit tests', () => { await expect( page.evaluate( async () => ( await window.deputy.DeputyCasePage.build() ) - .findFirstContributionSurveyHeading() + .findFirstContributionSurveyHeadingElement() .getAttribute( 'data-deputy-test' ) ) ).resolves.toBe( _targetId );