Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c7d9e5f

Browse files
committedJun 7, 2024··
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().
1 parent 4dc21f6 commit c7d9e5f

15 files changed

+354
-221
lines changed
 

‎src/modules/ia/models/CopyrightProblemsListing.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import decorateEditSummary from '../../../wiki/util/decorateEditSummary';
66
import MwApi from '../../../MwApi';
77
import changeTag from '../../../config/changeTag';
88
import warn from '../../../util/warn';
9+
import normalizeWikiHeading from '../../../wiki/util/normalizeWikiHeading';
910

1011
export interface SerializedCopyrightProblemsListingData {
1112
basic: boolean;
@@ -113,17 +114,24 @@ export default class CopyrightProblemsListing {
113114
el.parentElement.tagName === 'LI' ? el.parentElement.parentElement : el.parentElement
114115
).previousElementSibling;
115116

116-
while ( previousPivot != null && previousPivot.tagName !== 'H4' ) {
117+
let heading;
118+
// Search for a level 4 heading backwards.
119+
while (
120+
previousPivot != null &&
121+
// Set the ceiling to be immediately above for efficiency.
122+
( heading = normalizeWikiHeading( previousPivot, previousPivot.parentElement ) )
123+
?.level !== 4
124+
) {
117125
previousPivot = previousPivot.previousElementSibling;
118126
}
119127

120128
if ( previousPivot == null ) {
121129
return false;
122130
}
123131

124-
if ( previousPivot.querySelector( '.mw-headline' ) != null ) {
125-
// At this point, previousPivot is likely a MediaWiki level 4 heading.
126-
const h4Anchor = previousPivot.querySelector( '.mw-headline a' );
132+
// At this point, previousPivot is likely a MediaWiki level 4 heading.
133+
const h4Anchor = heading.h.querySelector( 'a' );
134+
if ( h4Anchor ) {
127135
listingPage = pagelinkToTitle( h4Anchor as HTMLAnchorElement );
128136

129137
// Identify if the page is a proper listing page (within the root page's

‎src/modules/ia/models/CopyrightProblemsSession.ts

+34-37
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import equalTitle from '../../../util/equalTitle';
77
import swapElements from '../../../util/swapElements';
88
import NewCopyrightProblemsListing from '../ui/NewCopyrightProblemsListing';
99
import normalizeTitle from '../../../wiki/util/normalizeTitle';
10+
import normalizeWikiHeading from '../../../wiki/util/normalizeWikiHeading';
1011

1112
/**
1213
* A CopyrightProblemsPage that represents a page that currently exists on a document.
@@ -116,11 +117,14 @@ export default class CopyrightProblemsSession extends CopyrightProblemsPage {
116117
}
117118

118119
/**
119-
*
120+
* Adds a panel containing the "new listing" buttons (single and multiple)
121+
* and the panel container (when filing a multiple-page listing) to the proper
122+
* location: either at the end of the copyright problems section or replacing
123+
* the redlink to the blank copyright problems page.
120124
*/
121125
addNewListingsPanel(): void {
122126
document.querySelectorAll(
123-
'.mw-headline > a, a.external, a.redlink'
127+
'.mw-headline a, .mw-heading a, a.external, a.redlink'
124128
).forEach( ( el ) => {
125129
const href = el.getAttribute( 'href' );
126130
const url = new URL( href, window.location.href );
@@ -133,61 +137,54 @@ export default class CopyrightProblemsSession extends CopyrightProblemsPage {
133137
CopyrightProblemsPage.getCurrentListingPage().getPrefixedText()
134138
)
135139
) {
136-
// Crawl backwards, avoiding common inline elements, to see if this is a standalone
137-
// line within the rendered text.
138-
let currentPivot: Element = el.parentElement;
139-
140-
while (
141-
currentPivot !== null &&
142-
[ 'I', 'B', 'SPAN', 'EM', 'STRONG' ].indexOf( currentPivot.tagName ) !== -1
143-
) {
144-
currentPivot = currentPivot.parentElement;
145-
}
146-
147-
// By this point, current pivot will be a <div>, <p>, or other usable element.
148-
if (
149-
!el.parentElement.classList.contains( 'mw-headline' ) &&
150-
( currentPivot == null ||
151-
currentPivot.children.length > 1 )
152-
) {
153-
return;
154-
} else if ( el.parentElement.classList.contains( 'mw-headline' ) ) {
155-
// "Edit source" button of an existing section heading.
156-
let headingBottom = el.parentElement.parentElement.nextElementSibling;
157-
let pos: InsertPosition = 'beforebegin';
140+
if ( el.classList.contains( 'external' ) || el.classList.contains( 'redlink' ) ) {
141+
// Keep crawling up and find the parent of this element that is directly
142+
// below the parser root or the current section.
143+
let currentPivot = el;
158144
while (
159-
headingBottom != null &&
160-
!/^H[123456]$/.test( headingBottom.tagName )
145+
currentPivot != null &&
146+
!currentPivot.classList.contains( 'mw-parser-output' ) &&
147+
[ 'A', 'I', 'B', 'SPAN', 'EM', 'STRONG' ]
148+
.indexOf( currentPivot.tagName ) !== -1
161149
) {
162-
headingBottom = headingBottom.nextElementSibling;
150+
currentPivot = currentPivot.parentElement;
163151
}
164152

165-
if ( headingBottom == null ) {
166-
headingBottom = el.parentElement.parentElement.parentElement;
167-
pos = 'beforeend';
153+
// We're now at the <p> or <div> or whatever.
154+
// Check if it only has one child (the tree that contains this element)
155+
// and if so, replace the links.
156+
157+
if ( currentPivot.children.length > 1 ) {
158+
return;
168159
}
169160

170-
// Add below today's section header.
171161
mw.loader.using( [
172162
'oojs-ui-core',
173163
'oojs-ui.styles.icons-interactions',
174164
'mediawiki.widgets',
175165
'mediawiki.widgets.TitlesMultiselectWidget'
176166
], () => {
177-
// H4
178-
headingBottom.insertAdjacentElement(
179-
pos,
180-
NewCopyrightProblemsListing()
181-
);
167+
swapElements( currentPivot, NewCopyrightProblemsListing() );
182168
} );
183169
} else {
170+
// This is in a heading. Let's place it after the section heading.
171+
const heading = normalizeWikiHeading( el );
172+
173+
if ( heading.root.classList.contains( 'dp-ia-upgraded' ) ) {
174+
return;
175+
}
176+
heading.root.classList.add( 'dp-ia-upgraded' );
177+
184178
mw.loader.using( [
185179
'oojs-ui-core',
186180
'oojs-ui.styles.icons-interactions',
187181
'mediawiki.widgets',
188182
'mediawiki.widgets.TitlesMultiselectWidget'
189183
], () => {
190-
swapElements( el, NewCopyrightProblemsListing() );
184+
heading.root.insertAdjacentElement(
185+
'afterend',
186+
NewCopyrightProblemsListing()
187+
);
191188
} );
192189
}
193190
}

‎src/session/DeputyRootSession.ts

+26-30
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import DeputyCasePage, { ContributionSurveyHeading } from '../wiki/DeputyCasePag
22
import DeputyCCISessionStartLink from '../ui/root/DeputyCCISessionStartLink';
33
import removeElement from '../util/removeElement';
44
import unwrapWidget from '../util/unwrapWidget';
5-
import sectionHeadingName from '../wiki/util/sectionHeadingName';
65
import {
76
DeputyMessageEvent,
87
DeputySessionRequestMessage,
@@ -13,9 +12,9 @@ import DeputyContributionSurveySection from '../ui/root/DeputyContributionSurvey
1312
import { SessionInformation } from './DeputySession';
1413
import { ArrayOrNot } from '../types';
1514
import DeputyMessageWidget from '../ui/shared/DeputyMessageWidget';
16-
import sectionHeadingId from '../wiki/util/sectionHeadingId';
1715
import last from '../util/last';
1816
import findNextSiblingElement from '../util/findNextSiblingElement';
17+
import normalizeWikiHeading from '../wiki/util/normalizeWikiHeading';
1918

2019
/**
2120
* The DeputyRootSession. Instantiated only when:
@@ -43,9 +42,10 @@ export default class DeputyRootSession {
4342

4443
casePage.findContributionSurveyHeadings()
4544
.forEach( ( heading: ContributionSurveyHeading ) => {
46-
const link = DeputyCCISessionStartLink( heading, casePage );
45+
const normalizedHeading = normalizeWikiHeading( heading );
46+
const link = DeputyCCISessionStartLink( normalizedHeading, casePage );
4747
startLink.push( link as HTMLElement );
48-
heading.appendChild( link );
48+
normalizedHeading.root.appendChild( link );
4949
} );
5050

5151
window.deputy.comms.addEventListener( 'sessionStarted', () => {
@@ -68,7 +68,7 @@ export default class DeputyRootSession {
6868
await mw.loader.using(
6969
[ 'oojs-ui-core', 'oojs-ui.styles.icons-content' ],
7070
() => {
71-
const firstHeading = casePage.findFirstContributionSurveyHeading();
71+
const firstHeading = casePage.findFirstContributionSurveyHeadingElement();
7272
if ( firstHeading ) {
7373
const stopButton = new OO.ui.ButtonWidget( {
7474
label: mw.msg( 'deputy.session.otherActive.button' ),
@@ -108,9 +108,9 @@ export default class DeputyRootSession {
108108
window.deputy.session.init();
109109
} );
110110

111-
casePage.normalizeSectionHeading(
111+
normalizeWikiHeading(
112112
firstHeading
113-
).insertAdjacentElement(
113+
).root.insertAdjacentElement(
114114
'beforebegin',
115115
unwrapWidget( messageBox )
116116
);
@@ -136,8 +136,8 @@ export default class DeputyRootSession {
136136
const lastActiveSection =
137137
DeputyRootSession.findFirstLastActiveSection( casePage );
138138
const firstSection =
139-
casePage.normalizeSectionHeading(
140-
casePage.findFirstContributionSurveyHeading()
139+
normalizeWikiHeading(
140+
casePage.findFirstContributionSurveyHeadingElement()
141141
);
142142

143143
// Insert element directly into widget (not as text, or else event
@@ -165,10 +165,10 @@ export default class DeputyRootSession {
165165
'deputy.session.continue.help' :
166166
'deputy.session.continue.help.fromStart',
167167
lastActiveSection ?
168-
sectionHeadingName( lastActiveSection ) :
168+
normalizeWikiHeading( lastActiveSection ).title :
169169
casePage.lastActiveSections[ 0 ]
170170
.replace( /_/g, ' ' ),
171-
sectionHeadingName( firstSection )
171+
firstSection.title
172172
),
173173
actions: [ continueButton ],
174174
closable: true
@@ -183,7 +183,7 @@ export default class DeputyRootSession {
183183
DeputyRootSession.continueSession( casePage );
184184
} else {
185185
DeputyRootSession.continueSession( casePage, [
186-
sectionHeadingId( firstSection )
186+
firstSection.id
187187
] );
188188
}
189189
window.deputy.comms.removeEventListener(
@@ -192,7 +192,7 @@ export default class DeputyRootSession {
192192
);
193193
} );
194194

195-
firstSection.insertAdjacentElement(
195+
firstSection.root.insertAdjacentElement(
196196
'beforebegin',
197197
unwrapWidget( messageBox )
198198
);
@@ -221,7 +221,7 @@ export default class DeputyRootSession {
221221
[ 'oojs-ui-core', 'oojs-ui.styles.icons-content' ],
222222
() => {
223223
const firstHeading =
224-
casePage.findFirstContributionSurveyHeading();
224+
casePage.findFirstContributionSurveyHeadingElement();
225225
if ( firstHeading ) {
226226
const messageBox = DeputyMessageWidget( {
227227
classes: [
@@ -232,9 +232,9 @@ export default class DeputyRootSession {
232232
message: mw.msg( 'deputy.session.tabActive.help' ),
233233
closable: true
234234
} );
235-
casePage.normalizeSectionHeading(
235+
normalizeWikiHeading(
236236
firstHeading
237-
).insertAdjacentElement(
237+
).root.insertAdjacentElement(
238238
'beforebegin',
239239
unwrapWidget( messageBox )
240240
);
@@ -264,7 +264,7 @@ export default class DeputyRootSession {
264264
const csHeadings = casePage.findContributionSurveyHeadings();
265265
for ( const lastActiveSection of casePage.lastActiveSections ) {
266266
for ( const heading of csHeadings ) {
267-
if ( sectionHeadingId( heading ) === lastActiveSection ) {
267+
if ( normalizeWikiHeading( heading ).id === lastActiveSection ) {
268268
return heading;
269269
}
270270
}
@@ -284,7 +284,7 @@ export default class DeputyRootSession {
284284
_casePage?: DeputyCasePage
285285
): Promise<void> {
286286
const sectionIds = ( Array.isArray( section ) ? section : [ section ] ).map(
287-
( _section ) => sectionHeadingId( _section )
287+
( _section ) => normalizeWikiHeading( _section ).id
288288
);
289289
// Save session to storage
290290
const casePage = _casePage ?? await DeputyCasePage.build();
@@ -438,7 +438,7 @@ export default class DeputyRootSession {
438438

439439
const activeSectionPromises = [];
440440
for ( const heading of this.casePage.findContributionSurveyHeadings() ) {
441-
const headingId = sectionHeadingId( heading );
441+
const headingId = normalizeWikiHeading( heading ).id;
442442

443443
if ( this.session.caseSections.indexOf( headingId ) !== -1 ) {
444444
activeSectionPromises.push(
@@ -509,8 +509,8 @@ export default class DeputyRootSession {
509509
* @param heading
510510
*/
511511
addSectionOverlay( casePage: DeputyCasePage, heading: ContributionSurveyHeading ): void {
512-
const normalizedHeading = casePage.normalizeSectionHeading( heading );
513-
const section = casePage.getContributionSurveySection( normalizedHeading );
512+
const normalizedHeading = normalizeWikiHeading( heading ).root;
513+
const section = casePage.getContributionSurveySection( normalizedHeading as HTMLElement );
514514
const list = section.find(
515515
( v ) => v instanceof HTMLElement && v.tagName === 'UL'
516516
) as HTMLUListElement;
@@ -582,7 +582,7 @@ export default class DeputyRootSession {
582582
return false;
583583
}
584584

585-
const sectionId = sectionHeadingId( heading );
585+
const sectionId = normalizeWikiHeading( heading ).id;
586586
this.sections.push( el );
587587
const lastActiveSession = this.session.caseSections.indexOf( sectionId );
588588
if ( lastActiveSession === -1 ) {
@@ -591,11 +591,7 @@ export default class DeputyRootSession {
591591
}
592592
await casePage.addActiveSection( sectionId );
593593

594-
if ( heading.parentElement.classList.contains( 'mw-heading' ) ) {
595-
heading.parentElement.insertAdjacentElement( 'afterend', el.render() );
596-
} else {
597-
heading.insertAdjacentElement( 'afterend', el.render() );
598-
}
594+
normalizeWikiHeading( heading ).root.insertAdjacentElement( 'afterend', el.render() );
599595
await el.loadData();
600596
mw.hook( 'deputy.load.cci.session' ).fire();
601597

@@ -628,9 +624,9 @@ export default class DeputyRootSession {
628624
const casePage = e0 instanceof DeputyContributionSurveySection ?
629625
e0.casePage : e0;
630626
const heading = e0 instanceof DeputyContributionSurveySection ?
631-
e0.heading : e1;
627+
e0.heading : normalizeWikiHeading( e1 );
632628

633-
const sectionId = sectionHeadingId( heading );
629+
const sectionId = heading.id;
634630
const sectionListIndex = this.sections.indexOf( el );
635631
if ( el != null && sectionListIndex !== -1 ) {
636632
this.sections.splice( sectionListIndex, 1 );
@@ -647,7 +643,7 @@ export default class DeputyRootSession {
647643
} else {
648644
await DeputyRootSession.setSession( this.session );
649645
await casePage.removeActiveSection( sectionId );
650-
this.addSectionOverlay( casePage, heading );
646+
this.addSectionOverlay( casePage, heading.h );
651647
}
652648
}
653649
}

‎src/ui/root/DeputyCCISessionStartLink.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { h } from 'tsx-dom';
2-
import DeputyCasePage, { ContributionSurveyHeading } from '../../wiki/DeputyCasePage';
3-
import sectionHeadingId from '../../wiki/util/sectionHeadingId';
2+
import DeputyCasePage from '../../wiki/DeputyCasePage';
3+
import { WikiHeading } from '../../wiki/util/normalizeWikiHeading';
44

55
/**
66
* The CCI session start link. Starts a CCI session when pressed.
@@ -10,14 +10,14 @@ import sectionHeadingId from '../../wiki/util/sectionHeadingId';
1010
* @return The link element to be displayed
1111
*/
1212
export default function (
13-
heading: ContributionSurveyHeading,
13+
heading: WikiHeading,
1414
casePage?: DeputyCasePage
1515
): JSX.Element {
1616
return <span class="deputy dp-sessionStarter">
1717
<span class="dp-sessionStarter-bracket">[</span>
1818
<a onClick={ async () => {
1919
if ( casePage && casePage.lastActiveSections.length > 0 ) {
20-
const headingId = sectionHeadingId( heading );
20+
const headingId = heading.id;
2121
if ( window.deputy.config.cci.openOldOnContinue.get() ) {
2222
if ( casePage.lastActiveSections.indexOf( headingId ) === -1 ) {
2323
await casePage.addActiveSection( headingId );
@@ -29,7 +29,7 @@ export default function (
2929
);
3030
}
3131
} else {
32-
await window.deputy.session.DeputyRootSession.startSession( heading );
32+
await window.deputy.session.DeputyRootSession.startSession( heading.h );
3333
}
3434
} }>{
3535
mw.message(

‎src/ui/root/DeputyContributionSurveySection.tsx

+14-20
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import DeputyContributionSurveyRow from './DeputyContributionSurveyRow';
66
import ContributionSurveyRow from '../../models/ContributionSurveyRow';
77
import ContributionSurveySection from '../../models/ContributionSurveySection';
88
import DeputyReviewDialog from './DeputyReviewDialog';
9-
import sectionHeadingName from '../../wiki/util/sectionHeadingName';
109
import getSectionId from '../../wiki/util/getSectionId';
1110
import getSectionHTML from '../../wiki/util/getSectionHTML';
1211
import removeElement from '../../util/removeElement';
@@ -25,6 +24,7 @@ import error from '../../util/error';
2524
import DeputyExtraneousElement from './DeputyExtraneousElement';
2625
import classMix from '../../util/classMix';
2726
import dangerModeConfirm from '../../util/dangerModeConfirm';
27+
import normalizeWikiHeading, { WikiHeading } from '../../wiki/util/normalizeWikiHeading';
2828

2929
/**
3030
* The contribution survey section UI element. This includes a list of revisions
@@ -38,7 +38,7 @@ export default class DeputyContributionSurveySection implements DeputyUIElement
3838

3939
casePage: DeputyCasePage;
4040
private _section: ContributionSurveySection;
41-
heading: HTMLHeadingElement;
41+
heading: WikiHeading;
4242
sectionNodes: Node[];
4343
originalList: HTMLElement;
4444
/**
@@ -261,14 +261,14 @@ export default class DeputyContributionSurveySection implements DeputyUIElement
261261
* @return the name of the section heading.
262262
*/
263263
get headingName(): string {
264-
return sectionHeadingName( this.heading );
264+
return this.heading.title;
265265
}
266266

267267
/**
268268
* @return the `n` of the section heading, if applicable.
269269
*/
270270
get headingN(): number {
271-
return sectionHeadingN( this.heading, this.headingName );
271+
return sectionHeadingN( this.heading );
272272
}
273273

274274
/**
@@ -279,7 +279,7 @@ export default class DeputyContributionSurveySection implements DeputyUIElement
279279
*/
280280
constructor( casePage: DeputyCasePage, heading: ContributionSurveyHeading ) {
281281
this.casePage = casePage;
282-
this.heading = casePage.normalizeSectionHeading( heading );
282+
this.heading = normalizeWikiHeading( heading );
283283
this.sectionNodes = casePage.getContributionSurveySection( heading );
284284
}
285285

@@ -467,10 +467,10 @@ export default class DeputyContributionSurveySection implements DeputyUIElement
467467
* @param toggle
468468
*/
469469
toggleSectionElements( toggle: boolean ) {
470-
const bottom: Node = this.heading.nextSibling ?? null;
470+
const bottom: Node = this.heading.root.nextSibling ?? null;
471471
for ( const sectionElement of this.sectionNodes ) {
472472
if ( toggle ) {
473-
this.heading.parentNode.insertBefore( sectionElement, bottom );
473+
this.heading.root.parentNode.insertBefore( sectionElement, bottom );
474474
} else {
475475
removeElement( sectionElement );
476476
}
@@ -686,13 +686,15 @@ export default class DeputyContributionSurveySection implements DeputyUIElement
686686
// Remove whatever section elements are still there.
687687
// They may have been greatly modified by the save.
688688
const sectionElements =
689-
this.casePage.getContributionSurveySection( this.heading );
689+
this.casePage.getContributionSurveySection(
690+
this.heading.root as HTMLElement
691+
);
690692
sectionElements.forEach( ( el ) => removeElement( el ) );
691693

692694
// Clear out section elements and re-append new ones to the DOM.
693695
this.sectionNodes = [];
694696
// Heading is preserved to avoid messing with IDs.
695-
const heading = this.heading;
697+
const heading = this.heading.root;
696698
const insertRef = heading.nextSibling ?? null;
697699
for ( const child of Array.from( element.childNodes ) ) {
698700
if ( !this.casePage.isContributionSurveyHeading( child ) ) {
@@ -707,17 +709,9 @@ export default class DeputyContributionSurveySection implements DeputyUIElement
707709
this._section = null;
708710
await this.getSection( Object.assign( wikitext, { revid } ) );
709711
await this.prepare();
710-
if ( heading.parentElement.classList.contains( 'mw-heading' ) ) {
711-
// Intentional recursive call
712-
heading.parentElement.insertAdjacentElement(
713-
'afterend', this.render()
714-
);
715-
} else {
716-
// Intentional recursive call
717-
heading.insertAdjacentElement(
718-
'afterend', this.render()
719-
);
720-
}
712+
heading.insertAdjacentElement(
713+
'afterend', this.render()
714+
);
721715
// Run this asynchronously.
722716
setTimeout( this.loadData.bind( this ), 0 );
723717
} else {

‎src/wiki/DeputyCasePage.ts

+13-48
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import DeputyCasePageWikitext from './DeputyCasePageWikitext';
2-
import sectionHeadingName from './util/sectionHeadingName';
32
import getPageTitle from './util/getPageTitle';
43
import DeputyCase from './DeputyCase';
5-
import sectionHeadingId from './util/sectionHeadingId';
6-
import isWikiHeading from './util/isWikiHeading';
7-
import getWikiHeadingLevel from './util/getWikiHeadingLevel';
84
import getSectionElements from './util/getSectionElements';
5+
import normalizeWikiHeading from './util/normalizeWikiHeading';
96

107
export type ContributionSurveyHeading = HTMLHeadingElement;
118

@@ -144,17 +141,13 @@ export default class DeputyCasePage extends DeputyCase {
144141
return false;
145142
}
146143

147-
// All headings (h1, h2, h3, h4, h5, h6)
148-
const headlineElement = this.parsoid ?
149-
el :
150-
el.querySelector<HTMLElement>( '.mw-headline' );
151-
// Handle DiscussionTools case (.mw-heading)
152-
return isWikiHeading( el ) &&
153-
headlineElement != null &&
144+
const heading = normalizeWikiHeading( el );
145+
return heading != null &&
146+
el === heading.h &&
154147
// eslint-disable-next-line security/detect-non-literal-regexp
155148
new RegExp(
156149
window.deputy.wikiConfig.cci.headingMatch.get()
157-
).test( headlineElement.innerText );
150+
).test( heading.title );
158151
}
159152

160153
/**
@@ -163,7 +156,7 @@ export default class DeputyCasePage extends DeputyCase {
163156
*
164157
* @return The <h*> element of the heading.
165158
*/
166-
findFirstContributionSurveyHeading(): ContributionSurveyHeading {
159+
findFirstContributionSurveyHeadingElement(): ContributionSurveyHeading {
167160
return this.findContributionSurveyHeadings()[ 0 ];
168161
}
169162

@@ -179,14 +172,12 @@ export default class DeputyCasePage extends DeputyCase {
179172
sectionIdentifier: string,
180173
useId = false
181174
): ContributionSurveyHeading {
182-
// No need to perform .mw-headline existence check here, already
183-
// done by `findContributionSurveyHeadings`
184175
return this.findContributionSurveyHeadings()
185176
.find(
186177
( v ) =>
187-
useId ?
188-
sectionHeadingId( v ) === sectionIdentifier :
189-
sectionHeadingName( v ) === sectionIdentifier
178+
normalizeWikiHeading( v )[
179+
useId ? 'id' : 'title'
180+
] === sectionIdentifier
190181
);
191182
}
192183

@@ -211,30 +202,6 @@ export default class DeputyCasePage extends DeputyCase {
211202
}
212203
}
213204

214-
/**
215-
* Normalizes a section heading. On some pages, DiscussionTools wraps the heading
216-
* around in a div, which breaks some assumptions with the DOM tree (e.g. that the
217-
* heading is immediately followed by section elements).
218-
*
219-
* This returns the element at the "root" level, i.e. the wrapping <div> when
220-
* DiscussionTools is active, or the <h2> when it is not.
221-
* @param heading
222-
*/
223-
normalizeSectionHeading( heading: HTMLElement ): ContributionSurveyHeading {
224-
if ( !this.isContributionSurveyHeading( heading ) ) {
225-
if ( !this.isContributionSurveyHeading( heading.parentElement ) ) {
226-
throw new Error( 'Provided section heading is not a valid section heading.' );
227-
} else {
228-
heading = heading.parentElement;
229-
}
230-
}
231-
// When DiscussionTools is being used, the header is wrapped in a div.
232-
if ( heading.parentElement.classList.contains( 'mw-heading' ) ) {
233-
heading = heading.parentElement;
234-
}
235-
return heading as ContributionSurveyHeading;
236-
}
237-
238205
/**
239206
* Gets all elements that are part of a contribution survey "section", that is
240207
* a set of elements including the section heading and all elements succeeding
@@ -251,15 +218,13 @@ export default class DeputyCasePage extends DeputyCase {
251218
* @return An array of all HTMLElements covered by the section
252219
*/
253220
getContributionSurveySection( sectionHeading: HTMLElement ): Node[] {
254-
// Normalize "sectionHeading" to use the h* element and not the .mw-heading span.
255-
sectionHeading = this.normalizeSectionHeading( sectionHeading );
256-
const sectionHeadingLevel = getWikiHeadingLevel( sectionHeading );
221+
const heading = normalizeWikiHeading( sectionHeading );
222+
const ceiling = heading.root.parentElement;
257223

258224
return getSectionElements(
259-
this.normalizeSectionHeading( sectionHeading ),
225+
heading.root as HTMLElement,
260226
( el ) =>
261-
isWikiHeading( el ) &&
262-
sectionHeadingLevel >= getWikiHeadingLevel( el )
227+
heading.level >= ( normalizeWikiHeading( el, ceiling )?.level ?? Infinity )
263228
);
264229
}
265230

‎src/wiki/util/findSectionHeading.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ export default function findSectionHeading(
1111
): HTMLElement | null {
1212
let currentN = 1;
1313

14-
const headlines = Array.from( document.querySelectorAll( 'h2 > .mw-headline' ) );
14+
const headlines = Array.from( document.querySelectorAll(
15+
// Old style headings
16+
[ 1, 2, 3, 4, 5, 6 ].map( v => `h${v} > .mw-headline` ).join( ',' ) +
17+
',' +
18+
// New style headings
19+
[ 1, 2, 3, 4, 5, 6 ].map( v => `mw-heading > h${v}` ).join( ',' )
20+
) );
1521
for ( const el of headlines ) {
1622
if ( el instanceof HTMLElement && el.innerText === sectionHeadingName ) {
1723
if ( currentN >= n ) {

‎src/wiki/util/getWikiHeadingLevel.ts

-23
This file was deleted.

‎src/wiki/util/index.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,19 @@ import getRevisionURL from './getRevisionURL';
1313
import getSectionElements from './getSectionElements';
1414
import getSectionHTML from './getSectionHTML';
1515
import getSectionId from './getSectionId';
16-
import getWikiHeadingLevel from './getWikiHeadingLevel';
1716
import guessAuthor from './guessAuthor';
1817
import isWikiHeading from './isWikiHeading';
1918
import msgEval from './msgEval';
2019
import normalizeTitle from './normalizeTitle';
20+
import normalizeWikiHeading from './normalizeWikiHeading';
2121
import nsId from './nsId';
2222
import openWindow from './openWindow';
2323
import pagelinkToTitle from './pagelinkToTitle';
2424
import parseDiffUrl from './parseDiffUrl';
2525
import performHacks from './performHacks';
2626
import purge from './purge';
2727
import renderWikitext from './renderWikitext';
28-
import sectionHeadingId from './sectionHeadingId';
2928
import sectionHeadingN from './sectionHeadingN';
30-
import sectionHeadingName from './sectionHeadingName';
3129
import toRedirectsObject from './toRedirectsObject';
3230
export default {
3331
decorateEditSummary: decorateEditSummary,
@@ -45,20 +43,18 @@ export default {
4543
getSectionElements: getSectionElements,
4644
getSectionHTML: getSectionHTML,
4745
getSectionId: getSectionId,
48-
getWikiHeadingLevel: getWikiHeadingLevel,
4946
guessAuthor: guessAuthor,
5047
isWikiHeading: isWikiHeading,
5148
msgEval: msgEval,
5249
normalizeTitle: normalizeTitle,
50+
normalizeWikiHeading: normalizeWikiHeading,
5351
nsId: nsId,
5452
openWindow: openWindow,
5553
pagelinkToTitle: pagelinkToTitle,
5654
parseDiffUrl: parseDiffUrl,
5755
performHacks: performHacks,
5856
purge: purge,
5957
renderWikitext: renderWikitext,
60-
sectionHeadingId: sectionHeadingId,
6158
sectionHeadingN: sectionHeadingN,
62-
sectionHeadingName: sectionHeadingName,
6359
toRedirectsObject: toRedirectsObject
6460
};

‎src/wiki/util/isWikiHeading.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
import normalizeWikiHeading from './normalizeWikiHeading';
2+
13
/**
24
* Check if a given parameter is a wikitext heading parsed into HTML.
35
*
4-
* This is its own function to account for different parse outputs (Legacy, Parsoid,
5-
* DiscussionTools, etc.)
6+
* Alias for `normalizeWikiHeading( el ) != null`.
67
*
78
* @param el The element to check
89
* @return `true` if the element is a heading, `false` otherwise
910
*/
1011
export default function isWikiHeading( el: Element ): boolean {
11-
return ( el.classList.contains( 'mw-heading' ) || /^H\d$/.test( el.tagName ) );
12+
return normalizeWikiHeading( el ) != null;
1213
}

‎src/wiki/util/normalizeWikiHeading.ts

+232
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import last from '../../util/last';
2+
3+
/**
4+
* Each WikiHeadingType implies specific fields in {@link WikiHeading}:
5+
*
6+
* - `PARSOID` implies that there is no headline element, and that the `h`
7+
* element is the root heading element. This means `h.innerText` will be
8+
* "Section title".
9+
* - `OLD` implies that there is a headline element and possibly an editsection
10+
* element, and that the `h` is the root heading element. This means that
11+
* `h.innerText` will be "Section title[edit | edit source]" or similar.
12+
* - `NEW` implies that there is a headline element and possibly an editsection
13+
* element, and that a `div` is the root heading element. This means that
14+
* `h.innerText` will be "Section title".
15+
*/
16+
export enum WikiHeadingType {
17+
PARSOID,
18+
OLD,
19+
NEW
20+
}
21+
22+
/**
23+
* A parsed wikitext heading.
24+
*/
25+
export interface WikiHeading {
26+
/**
27+
* The type of this heading.
28+
*/
29+
type: WikiHeadingType,
30+
/**
31+
* The root element of this heading. This refers to the topmost element
32+
* related to this heading, excluding the `<section>` which contains it.
33+
*/
34+
root: Element,
35+
/**
36+
* The `<h*>` element of this heading.
37+
*/
38+
h: HTMLHeadingElement,
39+
/**
40+
* The ID of this heading. Also known as the fragment. On Parsoid, this
41+
* is the `html5` fragment mode (mostly Unicode characters).
42+
*/
43+
id: string,
44+
/**
45+
* The title of this heading. This is the actual text that the reader can
46+
* see, and the actual text that matters. Unsupported syntax, such as using
47+
* `<math>` templates in the heading, may not work.
48+
*/
49+
title: string,
50+
/**
51+
* The level of this heading. Goes from 1 to 6, referring to h1 to h6.
52+
*/
53+
level: number
54+
}
55+
56+
/**
57+
* Get relevant information from an H* element in a section heading.
58+
*
59+
* @param headingElement The heading element
60+
* @return An object containing the relevant {@link WikiHeading} fields.
61+
*/
62+
function getHeadingElementInfo( headingElement: HTMLHeadingElement ):
63+
Pick<WikiHeading, 'h' | 'id' | 'title' | 'level'> {
64+
return {
65+
h: headingElement,
66+
id: headingElement.id,
67+
title: headingElement.innerText,
68+
level: +last( headingElement.tagName )
69+
};
70+
}
71+
72+
/**
73+
* Annoyingly, there are many different ways that a heading can be parsed
74+
* into depending on the version and the parser used for given wikitext.
75+
*
76+
* In order to properly perform such wiki heading checks, we need to identify
77+
* if a given element is part of a wiki heading, and perform a normalization
78+
* if so.
79+
*
80+
* Since this function needs to check many things before deciding if a given
81+
* HTML element is part of a section heading or not, this also acts as an
82+
* `isWikiHeading` check.
83+
*
84+
* The layout for a heading differs depending on the MediaWiki version:
85+
*
86+
* <b>On 1.43+ (Parser)</b>
87+
* ```html
88+
* <div class="mw-heading mw-heading2">
89+
* <h2 id="Parsed_wikitext...">Parsed <i>wikitext</i>...</h2>
90+
* <span class="mw-editsection>...</span>
91+
* </div>
92+
* ```
93+
*
94+
* <b>On Parsoid</b>
95+
* ```html
96+
* <h2 id="Parsed_wikitext...">Parsed <i>wikitext</i>...</h2>
97+
* ```
98+
*
99+
* <b>On pre-1.43</b>
100+
* ```html
101+
* <h2>
102+
* <span class="mw-headline" id="Parsed_wikitext...">Parsed <i>wikitext</i>...</span>
103+
* <span class="mw-editsection">...</span>
104+
* </h2>
105+
* ```
106+
*
107+
* <b>Worst case execution time</b> would be if this was run with an element which was
108+
* outside a heading and deeply nested within the page.
109+
*
110+
* Backwards-compatibility support may be removed in the future. This function does not
111+
* support Parsoid specification versions lower than 2.0.
112+
*
113+
* @param node The node to check for
114+
* @param ceiling An element which `node` must be in to be a valid heading.
115+
* This is set to the `.mw-parser-output` element by default.
116+
* @return The root heading element (can be an &lt;h2&gt; or &lt;div&gt;),
117+
* or `null` if it is not a valid heading.
118+
*/
119+
export default function normalizeWikiHeading( node: Node, ceiling?: Element ): WikiHeading | null {
120+
if ( node == null ) {
121+
// Not valid input, obviously.
122+
return null;
123+
}
124+
125+
const rootNode = node.getRootNode();
126+
127+
// Break out of text nodes until we hit an element node.
128+
while ( node.nodeType !== node.ELEMENT_NODE ) {
129+
node = node.parentNode;
130+
131+
if ( node === rootNode ) {
132+
// We've gone too far and hit the root. This is not a wiki heading.
133+
return null;
134+
}
135+
}
136+
137+
// node is now surely an element.
138+
let elementNode = node as Element;
139+
140+
// If this node is the 1.43+ heading root, return it immediately.
141+
if ( elementNode.classList.contains( 'mw-heading' ) ) {
142+
return {
143+
type: WikiHeadingType.NEW,
144+
root: elementNode,
145+
...getHeadingElementInfo(
146+
Array.from( elementNode.children )
147+
.find( v =>/^H[123456]$/.test( v.tagName ) ) as HTMLHeadingElement
148+
)
149+
};
150+
}
151+
152+
// Otherwise, we're either inside or outside a mw-heading.
153+
// To determine if we are inside or outside, we keep climbing up until
154+
// we either hit an <hN> or a given stop point.
155+
// The stop point is, by default, `.mw-parser-output`, which exists both in a
156+
// Parsoid document and in standard parser output. If such an element doesn't
157+
// exist in this document, we just stop at the root element.
158+
ceiling = ceiling ??
159+
elementNode.ownerDocument.querySelector( '.mw-parser-output' ) ??
160+
elementNode.ownerDocument.documentElement;
161+
162+
// While we haven't hit a heading, keep going up.
163+
while ( elementNode !== ceiling ) {
164+
if ( /^H[123456]$/.test( elementNode.tagName ) ) {
165+
// This element is a heading!
166+
// Now determine if this is a MediaWiki heading.
167+
168+
if ( elementNode.parentElement.classList.contains( 'mw-heading' ) ) {
169+
// This element's parent is a `div.mw-heading`!
170+
return {
171+
type: WikiHeadingType.NEW,
172+
root: elementNode.parentElement,
173+
...getHeadingElementInfo( elementNode as HTMLHeadingElement )
174+
};
175+
} else {
176+
const headline: HTMLElement = elementNode.querySelector( ':scope > .mw-headline' );
177+
if ( headline != null ) {
178+
// This element has a `.mw-headline` child!
179+
return {
180+
type: WikiHeadingType.OLD,
181+
root: elementNode,
182+
h: elementNode as HTMLHeadingElement,
183+
id: headline.id,
184+
title: headline.innerText,
185+
level: +last( elementNode.tagName )
186+
};
187+
} else if (
188+
elementNode.parentElement.tagName === 'SECTION' &&
189+
elementNode.parentElement.firstElementChild === elementNode
190+
) {
191+
// A <section> element is directly above this element, and it is the
192+
// first element of that section!
193+
// This is a specific format followed by the 2.8.0 MediaWiki Parsoid spec.
194+
// https://www.mediawiki.org/wiki/Specs/HTML/2.8.0#Headings_and_Sections
195+
return {
196+
type: WikiHeadingType.PARSOID,
197+
root: elementNode,
198+
h: elementNode as HTMLHeadingElement,
199+
id: elementNode.id,
200+
title: ( elementNode as HTMLElement ).innerText,
201+
level: +last( elementNode.tagName )
202+
};
203+
} else {
204+
// This is a heading, but we can't figure out how it works.
205+
// This usually means something inserted an <h2> into the DOM, and we
206+
// accidentally picked it up.
207+
// In that case, discard it.
208+
return null;
209+
}
210+
}
211+
} else if ( elementNode.classList.contains( 'mw-heading' ) ) {
212+
// This element is the `div.mw-heading`!
213+
// This usually happens when we selected an element from inside the
214+
// `span.mw-editsection` span.
215+
return {
216+
type: WikiHeadingType.NEW,
217+
root: elementNode,
218+
...getHeadingElementInfo(
219+
Array.from( elementNode.children )
220+
.find( v =>/^H[123456]$/.test( v.tagName ) ) as HTMLHeadingElement
221+
)
222+
};
223+
} else {
224+
// Haven't reached the top part of a heading yet, or we are not
225+
// in a heading. Keep climbing up the tree until we hit the ceiling.
226+
elementNode = elementNode.parentElement;
227+
}
228+
}
229+
230+
// We hit the ceiling. This is not a wiki heading.
231+
return null;
232+
}

‎src/wiki/util/sectionHeadingId.ts

-16
This file was deleted.

‎src/wiki/util/sectionHeadingN.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import last from '../../util/last';
2-
import sectionHeadingId from './sectionHeadingId';
32
import error from '../../util/error';
3+
import { WikiHeading } from './normalizeWikiHeading';
44

55
/**
66
* 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';
1919
* - Otherwise, the n is the last number on the ID if the ID than the heading name.
2020
*
2121
* @param heading The heading to check
22-
* @param headingName The name of the heading to check
2322
* @return The n, a number
2423
*/
25-
export default function ( heading: HTMLHeadingElement, headingName: string ): number {
24+
export default function sectionHeadingN( heading: WikiHeading ): number {
2625
try {
2726

2827
const headingNameEndPattern = /(?:\s|_)(\d+)/g;
2928
const headingIdEndPattern = /_(\d+)/g;
3029

31-
const headingId = sectionHeadingId( heading );
30+
const headingId = heading.id;
3231
const headingIdMatches = headingId.match( headingIdEndPattern );
33-
const headingNameMatches = headingName.match( headingNameEndPattern );
32+
const headingNameMatches = heading.title.match( headingNameEndPattern );
3433

3534
if ( headingIdMatches == null ) {
3635
return 1;

‎src/wiki/util/sectionHeadingName.ts

-22
This file was deleted.

‎tests/unit/browser/DeputyCasePageUnitTests.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ describe( 'DeputyCasePage implementation unit tests', () => {
224224
await expect(
225225
page.evaluate(
226226
async () => ( await window.deputy.DeputyCasePage.build() )
227-
.findFirstContributionSurveyHeading()
227+
.findFirstContributionSurveyHeadingElement()
228228
.getAttribute( 'data-deputy-test' )
229229
)
230230
).resolves.toBe( _targetId );

0 commit comments

Comments
 (0)
Please sign in to comment.