mwoffliner 1.17.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +355 -62
  2. package/docs/functional_architecture.md +34 -38
  3. package/extensions/wiktionary_fr.js +18 -17
  4. package/jest.config.cjs +2 -2
  5. package/lib/DOMUtils.js +0 -1
  6. package/lib/DOMUtils.js.map +1 -1
  7. package/lib/Downloader.d.ts +39 -31
  8. package/lib/Downloader.js +418 -349
  9. package/lib/Downloader.js.map +1 -1
  10. package/lib/Dump.d.ts +24 -10
  11. package/lib/Dump.js +95 -50
  12. package/lib/Dump.js.map +1 -1
  13. package/lib/Gadgets.d.ts +2 -1
  14. package/lib/Gadgets.js +7 -5
  15. package/lib/Gadgets.js.map +1 -1
  16. package/lib/Logger.d.ts +6 -6
  17. package/lib/Logger.js +33 -33
  18. package/lib/Logger.js.map +1 -1
  19. package/lib/MediaWiki.d.ts +14 -31
  20. package/lib/MediaWiki.js +119 -172
  21. package/lib/MediaWiki.js.map +1 -1
  22. package/lib/RedisStore.d.ts +3 -3
  23. package/lib/RedisStore.js +21 -23
  24. package/lib/RedisStore.js.map +1 -1
  25. package/lib/S3.js +1 -1
  26. package/lib/S3.js.map +1 -1
  27. package/lib/Templates.d.ts +3 -10
  28. package/lib/Templates.js +5 -14
  29. package/lib/Templates.js.map +1 -1
  30. package/lib/cli.js +7 -6
  31. package/lib/cli.js.map +1 -1
  32. package/lib/config.d.ts +10 -16
  33. package/lib/config.js +33 -55
  34. package/lib/config.js.map +1 -1
  35. package/lib/error.manager.d.ts +2 -2
  36. package/lib/error.manager.js +42 -49
  37. package/lib/error.manager.js.map +1 -1
  38. package/lib/i18n.d.ts +2 -0
  39. package/lib/i18n.js +53 -0
  40. package/lib/i18n.js.map +1 -0
  41. package/lib/mutex.d.ts +2 -1
  42. package/lib/mutex.js +2 -1
  43. package/lib/mutex.js.map +1 -1
  44. package/lib/mwoffliner.lib.js +370 -222
  45. package/lib/mwoffliner.lib.js.map +1 -1
  46. package/lib/parameterList.d.ts +20 -8
  47. package/lib/parameterList.js +30 -18
  48. package/lib/parameterList.js.map +1 -1
  49. package/lib/renderers/abstract.renderer.d.ts +48 -55
  50. package/lib/renderers/abstract.renderer.js +377 -98
  51. package/lib/renderers/abstract.renderer.js.map +1 -1
  52. package/lib/renderers/action-parse.renderer.d.ts +22 -3
  53. package/lib/renderers/action-parse.renderer.js +123 -81
  54. package/lib/renderers/action-parse.renderer.js.map +1 -1
  55. package/lib/renderers/renderer.builder.js +6 -69
  56. package/lib/renderers/renderer.builder.js.map +1 -1
  57. package/lib/renderers/rendering.context.d.ts +2 -3
  58. package/lib/renderers/rendering.context.js +6 -14
  59. package/lib/renderers/rendering.context.js.map +1 -1
  60. package/lib/sanitize-argument.d.ts +7 -4
  61. package/lib/sanitize-argument.js +99 -41
  62. package/lib/sanitize-argument.js.map +1 -1
  63. package/lib/util/FileManager.d.ts +36 -0
  64. package/lib/util/FileManager.js +246 -0
  65. package/lib/util/FileManager.js.map +1 -0
  66. package/lib/util/RedisKvs.js +4 -4
  67. package/lib/util/RedisKvs.js.map +1 -1
  68. package/lib/util/RedisQueue.js +1 -1
  69. package/lib/util/RedisQueue.js.map +1 -1
  70. package/lib/util/builders/url/action-parse.director.d.ts +2 -2
  71. package/lib/util/builders/url/action-parse.director.js +7 -7
  72. package/lib/util/builders/url/action-parse.director.js.map +1 -1
  73. package/lib/util/builders/url/api.director.d.ts +2 -4
  74. package/lib/util/builders/url/api.director.js +10 -17
  75. package/lib/util/builders/url/api.director.js.map +1 -1
  76. package/lib/util/builders/url/base.director.d.ts +0 -4
  77. package/lib/util/builders/url/base.director.js +0 -25
  78. package/lib/util/builders/url/base.director.js.map +1 -1
  79. package/lib/util/builders/url/basic.director.d.ts +1 -1
  80. package/lib/util/builders/url/basic.director.js +1 -1
  81. package/lib/util/builders/url/web.director.d.ts +1 -1
  82. package/lib/util/builders/url/web.director.js +2 -2
  83. package/lib/util/builders/url/web.director.js.map +1 -1
  84. package/lib/util/categories.d.ts +5 -5
  85. package/lib/util/categories.js +175 -174
  86. package/lib/util/categories.js.map +1 -1
  87. package/lib/util/const.d.ts +4 -8
  88. package/lib/util/const.js +9 -17
  89. package/lib/util/const.js.map +1 -1
  90. package/lib/util/customCssJs.d.ts +6 -0
  91. package/lib/util/customCssJs.js +70 -0
  92. package/lib/util/customCssJs.js.map +1 -0
  93. package/lib/util/dump.d.ts +15 -4
  94. package/lib/util/dump.js +282 -86
  95. package/lib/util/dump.js.map +1 -1
  96. package/lib/util/index.d.ts +1 -1
  97. package/lib/util/index.js +1 -1
  98. package/lib/util/index.js.map +1 -1
  99. package/lib/util/metaData.js.map +1 -1
  100. package/lib/util/misc.d.ts +23 -13
  101. package/lib/util/misc.js +90 -59
  102. package/lib/util/misc.js.map +1 -1
  103. package/lib/util/mw-api.d.ts +7 -5
  104. package/lib/util/mw-api.js +167 -221
  105. package/lib/util/mw-api.js.map +1 -1
  106. package/lib/util/pageListMainPage.d.ts +3 -0
  107. package/lib/util/pageListMainPage.js +10 -0
  108. package/lib/util/pageListMainPage.js.map +1 -0
  109. package/lib/util/pages.d.ts +30 -0
  110. package/lib/util/pages.js +146 -0
  111. package/lib/util/pages.js.map +1 -0
  112. package/lib/util/rewriteUrls.d.ts +2 -2
  113. package/lib/util/rewriteUrls.js +33 -31
  114. package/lib/util/rewriteUrls.js.map +1 -1
  115. package/lib/util/savePages.d.ts +8 -0
  116. package/lib/util/savePages.js +182 -0
  117. package/lib/util/savePages.js.map +1 -0
  118. package/lib/version.d.ts +1 -1
  119. package/lib/version.js +1 -1
  120. package/lib/version.js.map +1 -1
  121. package/offliner-definition.json +178 -90
  122. package/package.json +50 -49
  123. package/res/page_list_home.js +20 -0
  124. package/res/{article_not_found.svg → page_not_found.svg} +1 -1
  125. package/res/script.js +30 -45
  126. package/res/style.css +52 -68
  127. package/res/templates/download_error_placeholder.html +7 -8
  128. package/res/templates/javaScript.html +19 -0
  129. package/res/templates/pageFallback.html +9 -7
  130. package/res/templates/pageVector2022.html +9 -7
  131. package/res/templates/pageVectorLegacy.html +9 -7
  132. package/res/templates/{article_list_home.html → page_list_home.html} +1 -2
  133. package/translation/ar.json +21 -21
  134. package/translation/bn.json +1 -1
  135. package/translation/de.json +46 -19
  136. package/translation/en.json +54 -21
  137. package/translation/es.json +17 -19
  138. package/translation/fi.json +23 -3
  139. package/translation/fr.json +57 -21
  140. package/translation/he.json +8 -6
  141. package/translation/ia.json +19 -19
  142. package/translation/id.json +13 -13
  143. package/translation/it.json +13 -8
  144. package/translation/ko.json +55 -3
  145. package/translation/lb.json +5 -2
  146. package/translation/mk.json +5 -5
  147. package/translation/nl.json +57 -22
  148. package/translation/or.json +2 -2
  149. package/translation/pt.json +2 -2
  150. package/translation/qqq.json +55 -22
  151. package/translation/ru.json +2 -2
  152. package/translation/sc.json +2 -2
  153. package/translation/sk.json +62 -0
  154. package/translation/sl.json +54 -21
  155. package/translation/sv.json +20 -19
  156. package/translation/sw.json +45 -2
  157. package/translation/zh-hans.json +56 -22
  158. package/translation/zh-hant.json +26 -19
  159. package/lib/renderers/abstractDesktop.render.d.ts +0 -12
  160. package/lib/renderers/abstractDesktop.render.js +0 -67
  161. package/lib/renderers/abstractDesktop.render.js.map +0 -1
  162. package/lib/renderers/abstractMobile.render.d.ts +0 -12
  163. package/lib/renderers/abstractMobile.render.js +0 -54
  164. package/lib/renderers/abstractMobile.render.js.map +0 -1
  165. package/lib/renderers/rest-api.renderer.d.ts +0 -7
  166. package/lib/renderers/rest-api.renderer.js +0 -68
  167. package/lib/renderers/rest-api.renderer.js.map +0 -1
  168. package/lib/renderers/visual-editor.renderer.d.ts +0 -7
  169. package/lib/renderers/visual-editor.renderer.js +0 -76
  170. package/lib/renderers/visual-editor.renderer.js.map +0 -1
  171. package/lib/renderers/wikimedia-desktop.renderer.d.ts +0 -4
  172. package/lib/renderers/wikimedia-desktop.renderer.js +0 -7
  173. package/lib/renderers/wikimedia-desktop.renderer.js.map +0 -1
  174. package/lib/renderers/wikimedia-mobile.renderer.d.ts +0 -19
  175. package/lib/renderers/wikimedia-mobile.renderer.js +0 -243
  176. package/lib/renderers/wikimedia-mobile.renderer.js.map +0 -1
  177. package/lib/util/articleListMainPage.d.ts +0 -3
  178. package/lib/util/articleListMainPage.js +0 -10
  179. package/lib/util/articleListMainPage.js.map +0 -1
  180. package/lib/util/articles.d.ts +0 -26
  181. package/lib/util/articles.js +0 -102
  182. package/lib/util/articles.js.map +0 -1
  183. package/lib/util/builders/url/desktop.director.d.ts +0 -8
  184. package/lib/util/builders/url/desktop.director.js +0 -15
  185. package/lib/util/builders/url/desktop.director.js.map +0 -1
  186. package/lib/util/builders/url/mobile.director.d.ts +0 -8
  187. package/lib/util/builders/url/mobile.director.js +0 -15
  188. package/lib/util/builders/url/mobile.director.js.map +0 -1
  189. package/lib/util/builders/url/rest-api.director.d.ts +0 -8
  190. package/lib/util/builders/url/rest-api.director.js +0 -18
  191. package/lib/util/builders/url/rest-api.director.js.map +0 -1
  192. package/lib/util/builders/url/visual-editor.director.d.ts +0 -9
  193. package/lib/util/builders/url/visual-editor.director.js +0 -18
  194. package/lib/util/builders/url/visual-editor.director.js.map +0 -1
  195. package/lib/util/saveArticles.d.ts +0 -8
  196. package/lib/util/saveArticles.js +0 -411
  197. package/lib/util/saveArticles.js.map +0 -1
  198. package/res/article_list_home.js +0 -24
  199. package/res/content.parsoid.css +0 -196
  200. package/res/inserted_style.css +0 -45
  201. package/res/templates/categories.html +0 -11
  202. package/res/templates/lead_section_wrapper.html +0 -6
  203. package/res/templates/pageWikimediaDesktop.html +0 -24
  204. package/res/templates/pageWikimediaMobile.html +0 -24
  205. package/res/templates/section_wrapper.html +0 -5
  206. package/res/templates/subcategories.html +0 -23
  207. package/res/templates/subpages.html +0 -17
  208. package/res/templates/subsection_wrapper.html +0 -5
  209. package/res/webpHandler.js +0 -165
  210. package/res/wm_mobile_override_script.js +0 -15
  211. package/res/wm_mobile_override_style.css +0 -20
  212. package/translation/br.json +0 -8
  213. package/translation/dag.json +0 -9
  214. package/translation/es-formal.json +0 -29
  215. package/translation/ha.json +0 -10
  216. package/translation/hi.json +0 -9
  217. package/translation/ig.json +0 -10
  218. package/translation/kaa.json +0 -9
  219. package/translation/nb.json +0 -9
  220. package/translation/nqo.json +0 -8
  221. package/translation/pt-br.json +0 -10
  222. package/translation/ro.json +0 -9
  223. package/translation/scn.json +0 -8
  224. package/translation/sq.json +0 -9
  225. package/translation/te.json +0 -9
  226. package/translation/tn.json +0 -9
  227. package/translation/tr.json +0 -10
@@ -7,19 +7,24 @@ import RedisStore from '../RedisStore.js';
7
7
  import DOMUtils from '../DOMUtils.js';
8
8
  import DU from '../DOMUtils.js';
9
9
  import { config } from '../config.js';
10
+ import Downloader from '../Downloader.js';
10
11
  import { rewriteUrlsOfDoc } from '../util/rewriteUrls.js';
11
12
  import { footerTemplate } from '../Templates.js';
12
- import { getFullUrl, getMediaBase, getRelativeFilePath, interpolateTranslationString, encodeArticleIdForZimHtmlUrl, getStaticFiles } from '../util/misc.js';
13
+ import { getFullUrl, getMediaBase, getRelativeFilePath, encodePageTitleForZimHtmlUrl, makeZimPath } from '../util/misc.js';
13
14
  import { processStylesheetContent } from '../util/dump.js';
14
- import { isMainPage, isSubpage } from '../util/articles.js';
15
+ import { isMainPage, isSubpage } from '../util/pages.js';
16
+ import { buildCategoryTypeItems } from '../util/categories.js';
15
17
  export class Renderer {
16
- staticFilesListCommon = [];
18
+ staticFilesListCommon = new Set();
17
19
  constructor() {
18
- if (this.staticFilesListCommon.length === 0) {
19
- this.staticFilesListCommon = getStaticFiles(config.output.jsResourcesCommon, config.output.cssResourcesCommon);
20
+ if (this.staticFilesListCommon.size === 0) {
21
+ this.staticFilesListCommon = new Set([...config.output.jsResourcesCommon, ...config.output.cssResourcesCommon]);
20
22
  }
21
23
  }
22
- async treatAudioVideo(dump, srcCache, articleId, audioVideoEl) {
24
+ addDownloadErrorStaticFiles() {
25
+ this.staticFilesListCommon = new Set([...this.staticFilesListCommon, ...config.output.downloadErrorResources]);
26
+ }
27
+ async treatAudioVideo(dump, srcCache, pagePath, audioVideoEl) {
23
28
  const imageDependencies = [];
24
29
  const videoDependencies = [];
25
30
  const subtitles = [];
@@ -37,17 +42,17 @@ export class Renderer {
37
42
  }
38
43
  const chosenAudioVideoSourceEl = isVideo ? this.chooseBestVideoSource(audioVideoEl) : this.chooseBestAudioSource(audioVideoEl);
39
44
  if (!chosenAudioVideoSourceEl) {
40
- logger.warn(`Deleting audio/video HTML node missing an appropriate source in article '${articleId}':\n${audioVideoEl.outerHTML}`);
45
+ logger.warn(`Deleting audio/video HTML node missing an appropriate source in page at '${pagePath}':\n${audioVideoEl.outerHTML}`);
41
46
  DOMUtils.deleteNode(audioVideoEl);
42
47
  return { imageDependencies, videoDependencies, subtitles };
43
48
  }
44
49
  if (isVideo) {
45
- this.handleVideoPoster(audioVideoEl, articleId, imageDependencies, srcCache);
50
+ this.handleVideoPoster(audioVideoEl, pagePath, imageDependencies, srcCache);
46
51
  }
47
- this.updateAudioVideoSrc(chosenAudioVideoSourceEl, articleId, srcCache, videoDependencies);
52
+ this.updateAudioVideoSrc(chosenAudioVideoSourceEl, pagePath, srcCache, videoDependencies);
48
53
  const trackElements = Array.from(audioVideoEl.querySelectorAll('track'));
49
54
  for (const track of trackElements) {
50
- subtitles.push(await this.treatSubtitle(track, articleId));
55
+ subtitles.push(await this.treatSubtitle(track, pagePath));
51
56
  }
52
57
  return { imageDependencies, videoDependencies, subtitles };
53
58
  }
@@ -65,7 +70,7 @@ export class Renderer {
65
70
  const videoSourceEls = Array.from(videoEl.children).filter((child) => child.tagName === 'SOURCE');
66
71
  const originalSrc = videoEl.getAttribute('src');
67
72
  // Take into account the rare edge case where <video> has no <source> child
68
- if (videoSourceEls.length == 0) {
73
+ if (videoSourceEls.length === 0) {
69
74
  if (originalSrc && originalSrc.endsWith('.webm')) {
70
75
  // If video has a webm `src` attribute, this is an acceptable src
71
76
  return videoEl;
@@ -142,7 +147,7 @@ export class Renderer {
142
147
  audioEl.removeAttribute('src');
143
148
  audioEl.removeAttribute('resource');
144
149
  // Take into account the standard case where <audio> has no <source> child
145
- if (audioSourceEls.length == 0) {
150
+ if (audioSourceEls.length === 0) {
146
151
  if (originalSrc) {
147
152
  // If audio has a single `src` or `resource` attribute, this is an acceptable src
148
153
  // Set this source back in `src`, no matter where it originally was
@@ -175,11 +180,11 @@ export class Renderer {
175
180
  });
176
181
  return chosenAudioSourceEl;
177
182
  }
178
- handleVideoPoster(videoEl, articleId, imageDependencies, srcCache) {
183
+ handleVideoPoster(videoEl, pagePath, imageDependencies, srcCache) {
179
184
  const posterUrl = videoEl.getAttribute('poster');
180
185
  if (posterUrl) {
181
186
  const videoPosterUrl = getFullUrl(posterUrl, MediaWiki.baseUrl);
182
- const newVideoPosterUrl = getRelativeFilePath(articleId, getMediaBase(videoPosterUrl, true));
187
+ const newVideoPosterUrl = getRelativeFilePath(pagePath, getMediaBase(videoPosterUrl, true));
183
188
  if (posterUrl) {
184
189
  videoEl.setAttribute('poster', newVideoPosterUrl);
185
190
  }
@@ -191,7 +196,7 @@ export class Renderer {
191
196
  }
192
197
  }
193
198
  }
194
- updateAudioVideoSrc(chosenVideoSourceEl, articleId, srcCache, videoDependencies) {
199
+ updateAudioVideoSrc(chosenVideoSourceEl, pagePath, srcCache, videoDependencies) {
195
200
  /* Download content, but avoid duplicate calls */
196
201
  const sourceUrl = getFullUrl(chosenVideoSourceEl.getAttribute('src'), MediaWiki.baseUrl);
197
202
  // eslint-disable-next-line no-prototype-builtins
@@ -201,20 +206,20 @@ export class Renderer {
201
206
  }
202
207
  /* Set new URL for the video element */
203
208
  const fileBase = getMediaBase(sourceUrl, true);
204
- chosenVideoSourceEl.setAttribute('src', getRelativeFilePath(articleId, fileBase));
209
+ chosenVideoSourceEl.setAttribute('src', getRelativeFilePath(pagePath, fileBase));
205
210
  }
206
- async treatSubtitle(trackEle, articleId) {
211
+ async treatSubtitle(trackEle, pagePath) {
207
212
  const subtitleSourceUrl = getFullUrl(trackEle.getAttribute('src'), MediaWiki.baseUrl);
208
213
  const { title, lang } = QueryStringParser.parse(subtitleSourceUrl);
209
- // The source URL we get from Mediawiki article is in srt format, so we replace it to vtt which is standard subtitle trackformat for <track> src attribute.
214
+ // The source URL we get from Mediawiki page is in srt format, so we replace it to vtt which is standard subtitle trackformat for <track> src attribute.
210
215
  const vttFormatUrl = new URL(subtitleSourceUrl);
211
216
  vttFormatUrl.searchParams.set('trackformat', 'vtt');
212
- trackEle.setAttribute('src', `${getRelativeFilePath(articleId, title)}-${lang}.vtt`);
217
+ trackEle.setAttribute('src', `${getRelativeFilePath(pagePath, title)}-${lang}.vtt`);
213
218
  return vttFormatUrl.href;
214
219
  }
215
220
  treatImageFrames(dump, parsoidDoc, imageNode) {
216
221
  const image = imageNode.getElementsByTagName('img')[0] || imageNode.getElementsByTagName('video')[0];
217
- if (!this.shouldKeepNode(dump, imageNode, image)) {
222
+ if (!this.shouldKeepNode(imageNode, image)) {
218
223
  DOMUtils.deleteNode(imageNode);
219
224
  return;
220
225
  }
@@ -240,19 +245,27 @@ export class Renderer {
240
245
  thumbDiv.appendChild(thumbinnerDiv);
241
246
  imageNode.parentNode.replaceChild(thumbDiv, imageNode);
242
247
  }
243
- async treatImage(dump, srcCache, articleId, img) {
248
+ async treatImage(dump, srcCache, zimPath, img) {
244
249
  const imageDependencies = [];
245
- if (!this.shouldKeepImage(dump, img)) {
250
+ if (!this.shouldKeepImage(img)) {
246
251
  DOMUtils.deleteNode(img);
247
252
  return { imageDependencies };
248
253
  }
254
+ /* Preserve inline data: URL images (e.g. from mw.svg) as-is */
255
+ const imgSrc = img.getAttribute('src');
256
+ if (imgSrc.startsWith('data:')) {
257
+ img.setAttribute('loading', 'lazy');
258
+ return { imageDependencies };
259
+ }
260
+ if (dump.nopic && !this.isMathFallbackImage(img)) {
261
+ this.convertImageToPlaceholder(img);
262
+ return { imageDependencies };
263
+ }
249
264
  /* Rewrite image src attribute */
250
265
  const src = getFullUrl(img.getAttribute('src'), MediaWiki.baseUrl);
251
266
  let newSrc;
252
267
  try {
253
- const slashesInUrl = articleId.split('/').length - 1;
254
- const upStr = slashesInUrl ? '../'.repeat(slashesInUrl) : './';
255
- newSrc = upStr + getMediaBase(src, true);
268
+ newSrc = getRelativeFilePath(zimPath, getMediaBase(src, true));
256
269
  /* Download image, but avoid duplicate calls */
257
270
  // eslint-disable-next-line no-prototype-builtins
258
271
  if (!srcCache.hasOwnProperty(src)) {
@@ -273,14 +286,98 @@ export class Renderer {
273
286
  img.setAttribute('loading', 'lazy');
274
287
  return { imageDependencies };
275
288
  }
276
- shouldKeepImage(dump, img) {
289
+ convertImageToPlaceholder(img) {
290
+ const width = this.getImageDimension(img, 'width');
291
+ const height = this.getImageDimension(img, 'height');
292
+ const meaningfulAlt = this.getMeaningfulAltText(img);
293
+ const svgPayload = this.getPlaceholderSvgPayload(width, height, meaningfulAlt, this.shouldRenderVisibleAlt(width, height));
294
+ if (!img.getAttribute('width') && width) {
295
+ img.setAttribute('width', `${width}`);
296
+ }
297
+ if (!img.getAttribute('height') && height) {
298
+ img.setAttribute('height', `${height}`);
299
+ }
300
+ img.setAttribute('src', `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgPayload)}`);
301
+ img.removeAttribute('resource');
302
+ img.removeAttribute('srcset');
303
+ img.setAttribute('loading', 'lazy');
304
+ }
305
+ getImageDimension(img, attrName) {
306
+ const attrValue = Number(img.getAttribute(attrName));
307
+ if (Number.isFinite(attrValue) && attrValue > 0) {
308
+ return attrValue;
309
+ }
310
+ const styleValue = this.getImageDimensionFromStyle(img, attrName);
311
+ if (styleValue) {
312
+ return styleValue;
313
+ }
314
+ const dataAttrName = attrName === 'width' ? 'data-file-width' : 'data-file-height';
315
+ const dataAttrValue = Number(img.getAttribute(dataAttrName));
316
+ if (Number.isFinite(dataAttrValue) && dataAttrValue > 0) {
317
+ return dataAttrValue;
318
+ }
319
+ return null;
320
+ }
321
+ getImageDimensionFromStyle(img, attrName) {
322
+ const style = img.getAttribute('style');
323
+ if (!style) {
324
+ return null;
325
+ }
326
+ const regex = new RegExp(`(?:^|;)\\s*${attrName}\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:px)?\\s*(?:;|$)`, 'i');
327
+ const match = style.match(regex);
328
+ if (!match) {
329
+ return null;
330
+ }
331
+ const value = Math.round(Number(match[1]));
332
+ return Number.isFinite(value) && value > 0 ? value : null;
333
+ }
334
+ shouldRenderVisibleAlt(width, height) {
335
+ return !!width && !!height && width >= 48 && height >= 24;
336
+ }
337
+ getPlaceholderSvgPayload(width, height, meaningfulAlt, shouldRenderVisibleAlt) {
338
+ const safeWidth = width || 1;
339
+ const safeHeight = height || 1;
340
+ const title = meaningfulAlt ? `<title>${this.escapeXml(meaningfulAlt)}</title>` : '';
341
+ const defs = shouldRenderVisibleAlt ? '<defs><clipPath id="alt-text-clip"><rect x="0" y="0" width="100%" height="100%" /></clipPath></defs>' : '';
342
+ let text = '';
343
+ if (meaningfulAlt && shouldRenderVisibleAlt) {
344
+ const fontSize = Math.max(10, Math.min(16, Math.floor(safeHeight / 3)));
345
+ text =
346
+ `<g clip-path="url(#alt-text-clip)">` +
347
+ `<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" fill="#54595d" font-size="${fontSize}" font-family="sans-serif">${this.escapeXml(meaningfulAlt)}</text>` +
348
+ '</g>';
349
+ }
350
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${safeWidth}" height="${safeHeight}" viewBox="0 0 ${safeWidth} ${safeHeight}" preserveAspectRatio="none">${title}${defs}<rect width="100%" height="100%" fill="transparent"/>${text}</svg>`;
351
+ }
352
+ getMeaningfulAltText(img) {
353
+ const alt = img.getAttribute('alt');
354
+ if (!alt) {
355
+ return null;
356
+ }
357
+ const compactAlt = alt.replace(/\u00a0/g, ' ').trim();
358
+ return compactAlt.length > 0 ? compactAlt : null;
359
+ }
360
+ escapeXml(input) {
361
+ return input.replace(/[<>&"']/g, (char) => {
362
+ const entities = {
363
+ '<': '&lt;',
364
+ '>': '&gt;',
365
+ '&': '&amp;',
366
+ '"': '&quot;',
367
+ "'": '&apos;',
368
+ };
369
+ return entities[char];
370
+ });
371
+ }
372
+ isMathFallbackImage(img) {
277
373
  const imageNodeClass = img.getAttribute('class') || '';
374
+ return imageNodeClass.includes('mwe-math-fallback-image-inline') || img.getAttribute('typeof') === 'mw:Extension/math';
375
+ }
376
+ shouldKeepImage(img) {
278
377
  const src = img.getAttribute('src');
279
- return ((!dump.nopic || imageNodeClass.includes('mwe-math-fallback-image-inline') || img.getAttribute('typeof') === 'mw:Extension/math') &&
280
- src &&
281
- !src.includes('./Special:FilePath/'));
378
+ return !!src && !src.includes('./Special:FilePath/');
282
379
  }
283
- async treatMedias(parsoidDoc, dump, articleId) {
380
+ async treatMedias(parsoidDoc, dump, pagePath) {
284
381
  let imageDependencies = [];
285
382
  let videoDependencies = [];
286
383
  let subtitles = [];
@@ -290,13 +387,13 @@ export class Renderer {
290
387
  const srcCache = {};
291
388
  for (const audioVideoEl of audiosAndVideos) {
292
389
  // <video /> and <audio />
293
- const ret = await this.treatAudioVideo(dump, srcCache, articleId, audioVideoEl);
390
+ const ret = await this.treatAudioVideo(dump, srcCache, pagePath, audioVideoEl);
294
391
  imageDependencies = imageDependencies.concat(ret.imageDependencies);
295
392
  videoDependencies = videoDependencies.concat(ret.videoDependencies);
296
393
  subtitles = subtitles.concat(ret.subtitles);
297
394
  }
298
395
  for (const imgEl of imgs) {
299
- const ret = await this.treatImage(dump, srcCache, articleId, imgEl);
396
+ const ret = await this.treatImage(dump, srcCache, pagePath, imgEl);
300
397
  imageDependencies = imageDependencies.concat(ret.imageDependencies);
301
398
  }
302
399
  /* Treat image figures + special spans */
@@ -311,8 +408,8 @@ export class Renderer {
311
408
  isStillLinked(image) {
312
409
  return image && image.parentNode && image.parentNode.tagName === 'A';
313
410
  }
314
- shouldKeepNode(dump, imageNode, image) {
315
- return !dump.nopic && imageNode && image;
411
+ shouldKeepNode(imageNode, image) {
412
+ return !!(imageNode && image);
316
413
  }
317
414
  makeThumbDiv(dump, parsoidDoc, imageNode) {
318
415
  const imageNodeClass = imageNode.getAttribute('class') || '';
@@ -336,16 +433,57 @@ export class Renderer {
336
433
  }
337
434
  return thumbDiv;
338
435
  }
436
+ getPositiveIntegerAttribute(node, attribute) {
437
+ const val = Number.parseInt(node.getAttribute(attribute), 10);
438
+ return Number.isFinite(val) && val > 0 ? val : undefined;
439
+ }
440
+ getRequestedImageWidths(doc) {
441
+ const requestedWidths = {};
442
+ const setRequestedWidth = (url, width) => {
443
+ const prevWidth = requestedWidths[url] || 0;
444
+ requestedWidths[url] = Math.max(prevWidth, width);
445
+ };
446
+ for (const image of Array.from(doc.getElementsByTagName('img'))) {
447
+ const imageSrc = image.getAttribute('src');
448
+ const width = this.getPositiveIntegerAttribute(image, 'width') || this.getPositiveIntegerAttribute(image, 'data-width');
449
+ if (!imageSrc || !width) {
450
+ continue;
451
+ }
452
+ try {
453
+ setRequestedWidth(getFullUrl(imageSrc, MediaWiki.baseUrl), width);
454
+ }
455
+ catch {
456
+ continue;
457
+ }
458
+ }
459
+ for (const video of Array.from(doc.getElementsByTagName('video'))) {
460
+ const posterSrc = video.getAttribute('poster');
461
+ const width = this.getPositiveIntegerAttribute(video, 'width');
462
+ if (!posterSrc || !width) {
463
+ continue;
464
+ }
465
+ try {
466
+ setRequestedWidth(getFullUrl(posterSrc, MediaWiki.baseUrl), width);
467
+ }
468
+ catch {
469
+ continue;
470
+ }
471
+ }
472
+ return requestedWidths;
473
+ }
339
474
  // TODO: The first part of this method is common for all renders
340
475
  async processHtml(processHtmlOpts) {
341
- const { html, dump, articleId, articleDetail, displayTitle, moduleDependencies, callback } = processHtmlOpts;
342
- let { articleSubtitle } = processHtmlOpts;
476
+ const { html, dump, pageTitle, pageDetail, displayTitle, categoryMembers, categoriesHtml, moduleDependencies, callback } = processHtmlOpts;
477
+ let { pageSubtitle } = processHtmlOpts;
343
478
  let imageDependencies = [];
344
479
  let videoDependencies = [];
345
480
  let mediaDependencies = [];
346
481
  let subtitles = [];
347
482
  let doc = domino.createDocument(html);
348
- const ruRet = await rewriteUrlsOfDoc(doc, articleId, dump);
483
+ // Simplification for now, ZIM path = pageTitle
484
+ const pagePath = makeZimPath(pageTitle);
485
+ const pageItems = [];
486
+ const ruRet = await rewriteUrlsOfDoc(doc, pagePath, dump);
349
487
  doc = ruRet.doc;
350
488
  mediaDependencies = mediaDependencies.concat(ruRet.mediaDependencies
351
489
  .filter((a) => a)
@@ -353,27 +491,81 @@ export class Renderer {
353
491
  const path = getMediaBase(url, false);
354
492
  return { url, path };
355
493
  }));
356
- doc = this.applyOtherTreatments(doc, dump, articleId);
357
- const tmRet = await this.treatMedias(doc, dump, articleId);
494
+ // applyOtherTreatments must run before treatMedias so that iframe
495
+ // placeholders (e.g. YouTube thumbnails) are already in the DOM
496
+ // when treatMedias processes <img> tags for download into the ZIM.
497
+ doc = await this.applyOtherTreatments(doc, dump, pagePath);
498
+ const imageRequestedWidths = this.getRequestedImageWidths(doc);
499
+ /* The content of a category page, listing all its members */
500
+ if (pageDetail.categoryinfo) {
501
+ const categoryinfo = categoryMembers.categoryinfo;
502
+ const categoryContent = doc.createElement('div');
503
+ categoryContent.lang = pageDetail.pagelang;
504
+ categoryContent.dir = pageDetail.pagedir;
505
+ categoryContent.classList.add('mw-category-generated');
506
+ if (categoryinfo.size) {
507
+ const categoryCollation = MediaWiki.metaData.categoryCollation;
508
+ const numericSorting = categoryCollation === 'numeric' || (categoryCollation.startsWith('uca-') && categoryCollation.endsWith('-u-kn'));
509
+ moduleDependencies.styleDependenciesList.push('mediawiki.action.styles');
510
+ await buildCategoryTypeItems('subcats', categoryinfo, categoryMembers, pagePath, pageDetail, doc, dump, numericSorting, moduleDependencies, pageTitle, categoryContent, pageItems);
511
+ await buildCategoryTypeItems('pages', categoryinfo, categoryMembers, pagePath, pageDetail, doc, dump, numericSorting, moduleDependencies, pageTitle, categoryContent, pageItems);
512
+ await buildCategoryTypeItems('files', categoryinfo, categoryMembers, pagePath, pageDetail, doc, dump, numericSorting, moduleDependencies, pageTitle, categoryContent, pageItems);
513
+ if (MediaWiki.getCategories) {
514
+ const p = doc.createElement('p');
515
+ p.innerHTML = dump.t('categoryLimited');
516
+ categoryContent.appendChild(p);
517
+ }
518
+ }
519
+ else {
520
+ const p = doc.createElement('p');
521
+ p.innerHTML = dump.t('categoryEmpty');
522
+ categoryContent.appendChild(p);
523
+ }
524
+ const ruRetCategoryContent = await rewriteUrlsOfDoc(categoryContent, pagePath, dump);
525
+ doc.getElementsByTagName('body')[0].appendChild(ruRetCategoryContent.doc);
526
+ }
527
+ const tmRet = await this.treatMedias(doc, dump, pagePath);
358
528
  doc = tmRet.doc;
359
529
  // Subtitle
360
- if (articleSubtitle) {
361
- let articleSubtitleDoc = domino.createDocument(articleSubtitle);
362
- const ruRetSubtitle = await rewriteUrlsOfDoc(articleSubtitleDoc, articleId, dump);
363
- articleSubtitleDoc = ruRetSubtitle.doc;
364
- articleSubtitle = articleSubtitleDoc.getElementsByTagName('body')[0].innerHTML;
530
+ if (pageSubtitle) {
531
+ let pageSubtitleDoc = domino.createDocument(pageSubtitle);
532
+ const ruRetSubtitle = await rewriteUrlsOfDoc(pageSubtitleDoc, pagePath, dump);
533
+ pageSubtitleDoc = ruRetSubtitle.doc;
534
+ pageSubtitle = pageSubtitleDoc.getElementsByTagName('body')[0].innerHTML;
535
+ }
536
+ // List of categories the page is a member of
537
+ if (categoriesHtml) {
538
+ let categoriesDoc = domino.createDocument(categoriesHtml);
539
+ // Remove categories which do not have an associated page
540
+ const catLinksDiv = categoriesDoc.getElementById('catlinks');
541
+ const listItems = catLinksDiv.getElementsByTagName('li');
542
+ for (const listItem of Array.from(listItems)) {
543
+ const link = listItem.getElementsByTagName('a')[0];
544
+ if (Array.from(link.classList).includes('new')) {
545
+ listItem.parentNode.removeChild(listItem);
546
+ }
547
+ }
548
+ const nbRemainingLinks = catLinksDiv.getElementsByTagName('li').length;
549
+ // Rewrite and add categories HTML only if there is at least one category remaining in the list
550
+ if (nbRemainingLinks > 0) {
551
+ const ruRetCategories = await rewriteUrlsOfDoc(categoriesDoc, pagePath, dump);
552
+ categoriesDoc = ruRetCategories.doc;
553
+ doc.getElementsByTagName('body')[0].appendChild(categoriesDoc.getElementById('catlinks'));
554
+ }
365
555
  }
366
556
  videoDependencies = videoDependencies.concat(tmRet.videoDependencies
367
557
  .filter((a) => a)
368
558
  .map((url) => {
369
559
  const path = getMediaBase(url, false);
370
- return { url, path };
560
+ const width = imageRequestedWidths[url];
561
+ return width ? { url, path, width } : { url, path };
371
562
  }));
372
563
  imageDependencies = imageDependencies.concat(tmRet.imageDependencies
373
564
  .filter((a) => a)
374
565
  .map((url) => {
375
566
  const path = getMediaBase(url, false);
376
- return { url, path };
567
+ const width = imageRequestedWidths[url];
568
+ return width ? { url, path, width } : { url, path };
377
569
  }));
378
570
  subtitles = subtitles.concat(tmRet.subtitles
379
571
  .filter((a) => a)
@@ -382,15 +574,16 @@ export class Renderer {
382
574
  const path = `${title}-${lang}.vtt`;
383
575
  return { url, path };
384
576
  }));
385
- if (!isMainPage(articleId) && dump.customProcessor?.preProcessArticle) {
386
- doc = await dump.customProcessor.preProcessArticle(articleId, doc);
577
+ if (!isMainPage(pageTitle) && dump.customProcessor?.preProcessPage) {
578
+ doc = await dump.customProcessor.preProcessPage(pageTitle, doc);
387
579
  }
388
- let templatedDoc = callback(moduleDependencies, articleId);
389
- templatedDoc = await this.mergeTemplateDoc(templatedDoc, doc, dump, articleDetail, RedisStore.articleDetailXId, articleId, displayTitle, articleSubtitle);
390
- if (dump.customProcessor && dump.customProcessor.postProcessArticle) {
391
- templatedDoc = await dump.customProcessor.postProcessArticle(articleId, templatedDoc);
580
+ const templateDoc = callback(moduleDependencies, pagePath);
581
+ const pageFinalDoc = domino.createDocument(templateDoc.documentElement.outerHTML);
582
+ await this.mergeTemplateDoc(pageFinalDoc, doc, dump, pageDetail, pageTitle, displayTitle, pageSubtitle);
583
+ if (dump.customProcessor && dump.customProcessor.postProcessPage) {
584
+ await dump.customProcessor.postProcessPage(pageTitle, pageFinalDoc);
392
585
  }
393
- let outHtml = templatedDoc.documentElement.outerHTML;
586
+ let outHtml = pageFinalDoc.documentElement.outerHTML;
394
587
  if (dump.opts.minifyHtml) {
395
588
  outHtml = await htmlMinifier.minify(outHtml, {
396
589
  removeComments: true,
@@ -401,46 +594,70 @@ export class Renderer {
401
594
  minifyCSS: true,
402
595
  });
403
596
  }
404
- const finalHTML = '<!DOCTYPE html>\n' + outHtml;
597
+ const div = doc.createElement('div');
598
+ div.innerHTML = displayTitle;
599
+ const displayTitleText = div.innerText;
600
+ pageItems.push({
601
+ pageTitle,
602
+ zimPath: pagePath,
603
+ zimTitle: displayTitleText ? displayTitleText : pageTitle,
604
+ htmlContent: '<!DOCTYPE html>\n' + outHtml,
605
+ });
405
606
  return {
406
- finalHTML,
607
+ items: pageItems,
407
608
  mediaDependencies,
408
609
  imageDependencies,
409
610
  videoDependencies,
611
+ moduleDependencies,
410
612
  subtitles,
613
+ needsDownloadErrorStaticFiles: false,
411
614
  };
412
615
  }
413
- async mergeTemplateDoc(htmlTemplateDoc, parsoidDoc, dump, articleDetail, articleDetailXId, articleId, displayTitle, articleSubtitle) {
616
+ async mergeTemplateDoc(htmlTemplateDoc, parsoidDoc, dump, pageDetail, pageTitle, displayTitle, pageSubtitle) {
414
617
  /* Create final document by merging template and parsoid documents */
415
618
  const mwContentText = htmlTemplateDoc.getElementById('mw-content-text');
416
- mwContentText.lang = articleDetail.pagelang;
417
- mwContentText.dir = articleDetail.pagedir;
418
- mwContentText.classList.add('mw-content-' + articleDetail.pagedir);
619
+ mwContentText.lang = pageDetail.pagelang;
620
+ mwContentText.dir = pageDetail.pagedir;
621
+ mwContentText.classList.add('mw-content-' + pageDetail.pagedir);
419
622
  mwContentText.innerHTML = parsoidDoc.getElementsByTagName('body')[0].innerHTML;
420
623
  /* Title */
421
- const articleTitle = htmlTemplateDoc.getElementById('title_0') ? htmlTemplateDoc.getElementById('title_0').textContent : articleId.replace(/_/g, ' ');
422
- htmlTemplateDoc.getElementsByTagName('title')[0].innerHTML = articleTitle;
624
+ let htmlTitle;
625
+ if (htmlTemplateDoc.getElementById('title_0')) {
626
+ htmlTitle = htmlTemplateDoc.getElementById('title_0').innerText;
627
+ }
628
+ else if (displayTitle) {
629
+ // displayTitle may contain HTML markup (e.g. italics), which has no place in the <title> tag
630
+ const titleDiv = htmlTemplateDoc.createElement('div');
631
+ titleDiv.innerHTML = displayTitle;
632
+ htmlTitle = titleDiv.textContent;
633
+ }
634
+ else {
635
+ htmlTitle = pageTitle;
636
+ }
637
+ htmlTemplateDoc.getElementsByTagName('title')[0].textContent = htmlTitle;
423
638
  // Set inline page title when missing
424
639
  const inlineTitle = htmlTemplateDoc.getElementById('firstHeading');
425
640
  if (inlineTitle && !inlineTitle.innerHTML) {
426
- inlineTitle.innerHTML = displayTitle || articleTitle;
641
+ inlineTitle.innerHTML = displayTitle || htmlTitle;
427
642
  }
428
643
  DOMUtils.deleteNode(htmlTemplateDoc.getElementById('titleHeading'));
429
644
  /* Subpage */
430
645
  if (this.constructor.name === 'ActionParseRenderer') {
431
646
  const mwContentSubtitle = htmlTemplateDoc.getElementById('mw-content-subtitle');
432
- mwContentSubtitle.innerHTML = articleSubtitle || '';
647
+ mwContentSubtitle.innerHTML = pageSubtitle || '';
433
648
  }
434
- else if (isSubpage(articleId) && !isMainPage(articleId)) {
649
+ else if (isSubpage(pageTitle) && !isMainPage(pageTitle)) {
435
650
  const headingNode = htmlTemplateDoc.getElementById('mw-content-text');
436
651
  const subpagesNode = htmlTemplateDoc.createElement('span');
437
- const parents = articleId.split('/');
652
+ const parents = pageTitle.split('/');
438
653
  parents.pop();
439
654
  let subpages = '';
440
655
  await Promise.all(parents.map(async (parent) => {
441
656
  const label = parent.replace(/_/g, ' ');
442
- const isParentMirrored = await articleDetailXId.exists(`${articleId.split(parent)[0]}${parent}`);
443
- subpages += `&lt; ${isParentMirrored ? `<a href="${'../'.repeat(parents.length)}${encodeArticleIdForZimHtmlUrl(`${articleId.split(parent)[0]}${parent}`)}" title="${label}">` : ''}${label}${isParentMirrored ? '</a> ' : ' '}`;
657
+ const isParentMirrored = await RedisStore.pagesStore.exists(`${pageTitle.split(parent)[0]}${parent}`);
658
+ subpages += `&lt; ${isParentMirrored
659
+ ? `<a href="${getRelativeFilePath(makeZimPath(pageTitle), encodePageTitleForZimHtmlUrl(`${pageTitle.split(parent)[0]}${parent}`))}" title="${label}">`
660
+ : ''}${label}${isParentMirrored ? '</a> ' : ' '}`;
444
661
  }));
445
662
  subpagesNode.innerHTML = subpages;
446
663
  subpagesNode.setAttribute('class', 'subpages');
@@ -449,31 +666,23 @@ export class Renderer {
449
666
  /* Set footer */
450
667
  const div = htmlTemplateDoc.createElement('div');
451
668
  /* Revision date */
452
- const date = new Date(articleDetail.timestamp);
453
- const lastEditedOnString = date
454
- ? interpolateTranslationString(dump.strings.LAST_EDITED_ON, {
455
- date: date.toISOString().substring(0, 10),
456
- })
457
- : null;
669
+ const date = new Date(pageDetail.timestamp);
670
+ const lastEditedOnString = date ? dump.t('LAST_EDITED_ON', { date: date.toISOString().substring(0, 10) }) : null;
458
671
  const creatorLink = '<a class="external text" ' +
459
672
  `${lastEditedOnString ? `title="${lastEditedOnString}"` : ''} ` +
460
- `href="${MediaWiki.webUrl.href}?title=${encodeURIComponent(articleId)}&oldid=${articleDetail.revisionId}">` +
673
+ `href="${MediaWiki.webUrl.href}?title=${encodeURIComponent(pageTitle.replace(/ /g, '_'))}&oldid=${pageDetail.revisionId}">` +
461
674
  `${dump.mwMetaData.title || dump.mwMetaData.creator}</a>`;
462
675
  const licenseLink = (dump.mwMetaData.licenseUrl ? `<a class="external text" href="${dump.mwMetaData.licenseUrl}">` : '') +
463
- (dump.mwMetaData.licenseName || dump.mwMetaData.licenseUrl || dump.strings.LICENSE_UNKNOWN).replace(/</g, '&lt;') +
676
+ (dump.mwMetaData.licenseName || dump.mwMetaData.licenseUrl || dump.t('LICENSE_UNKNOWN')).replace(/</g, '&lt;') +
464
677
  (dump.mwMetaData.licenseUrl ? `</a>` : '');
465
678
  div.innerHTML = footerTemplate({
466
- disclaimer: interpolateTranslationString(dump.strings.DISCLAIMER, {
467
- creator: creatorLink,
468
- license: licenseLink,
469
- }),
470
- strings: dump.strings,
679
+ disclaimer: dump.t('DISCLAIMER', { creator: creatorLink, license: licenseLink }),
471
680
  });
472
- htmlTemplateDoc.getElementById('mw-content-text').appendChild(div);
681
+ mwContentText.appendChild(div);
473
682
  this.addNoIndexCommentToElement(div);
474
683
  /* Geo-coordinates */
475
- if (articleDetail.coordinates) {
476
- const geoCoordinates = articleDetail.coordinates;
684
+ if (pageDetail.coordinates) {
685
+ const geoCoordinates = pageDetail.coordinates;
477
686
  const metaNode = htmlTemplateDoc.createElement('meta');
478
687
  metaNode.name = 'geo.position';
479
688
  metaNode.content = geoCoordinates;
@@ -485,13 +694,81 @@ export class Renderer {
485
694
  const slices = element.parentElement.innerHTML.split(element.outerHTML);
486
695
  element.parentElement.innerHTML = `${slices[0]}<!--htdig_noindex-->${element.outerHTML}<!--/htdig_noindex-->${slices[1]}`;
487
696
  }
488
- removeIframeTags(parsoidDoc) {
489
- // Remove all <iframe> tags
697
+ // Rewrite iframe tags into external placeholders
698
+ processIframeTags(parsoidDoc) {
490
699
  const iframes = Array.from(parsoidDoc.getElementsByTagName('iframe'));
491
700
  for (const iframe of iframes) {
492
- DU.deleteNode(iframe);
701
+ const src = iframe.getAttribute('src');
702
+ if (!src) {
703
+ DU.deleteNode(iframe);
704
+ continue;
705
+ }
706
+ let fullSrc = src;
707
+ try {
708
+ fullSrc = getFullUrl(src, MediaWiki.baseUrl);
709
+ }
710
+ catch {
711
+ // Keep original src when URL parsing fails
712
+ }
713
+ const placeholder = this.isYouTubeIframeUrl(fullSrc)
714
+ ? this.createYouTubePlaceholder(parsoidDoc, iframe, fullSrc, this.extractYouTubeVideoId(fullSrc))
715
+ : this.createExternalIframePlaceholder(parsoidDoc, iframe, fullSrc);
716
+ if (iframe.parentNode) {
717
+ iframe.parentNode.replaceChild(placeholder, iframe);
718
+ }
719
+ else {
720
+ DU.deleteNode(iframe);
721
+ }
722
+ }
723
+ }
724
+ isYouTubeIframeUrl(url) {
725
+ const lowerUrl = url.toLowerCase();
726
+ return lowerUrl.includes('youtube.com/embed/') || lowerUrl.includes('youtube-nocookie.com/embed/') || lowerUrl.includes('youtu.be/') || lowerUrl.includes('youtube.com/watch');
727
+ }
728
+ extractYouTubeVideoId(url) {
729
+ const match = url.match(/(?:embed\/|v=|\.be\/)([a-zA-Z0-9_-]+)/);
730
+ return match ? match[1] : null;
731
+ }
732
+ createExternalIframePlaceholder(parsoidDoc, iframe, originalUrl) {
733
+ const card = parsoidDoc.createElement('div');
734
+ this.copyNodeAttributes(iframe, card);
735
+ const iframeClass = card.getAttribute('class');
736
+ card.setAttribute('class', iframeClass ? `${iframeClass} mwo-external-video-card` : 'mwo-external-video-card');
737
+ const link = parsoidDoc.createElement('a');
738
+ link.setAttribute('class', 'external');
739
+ link.setAttribute('href', originalUrl);
740
+ link.textContent = 'Open embedded content (external)';
741
+ card.appendChild(link);
742
+ return card;
743
+ }
744
+ copyNodeAttributes(sourceNode, targetNode) {
745
+ for (let attributeIndex = 0; attributeIndex < sourceNode.attributes.length; attributeIndex++) {
746
+ const attribute = sourceNode.attributes.item(attributeIndex);
747
+ if (!attribute) {
748
+ continue;
749
+ }
750
+ targetNode.setAttribute(attribute.name, attribute.value);
493
751
  }
494
752
  }
753
+ createYouTubePlaceholder(parsoidDoc, iframe, originalUrl, videoId) {
754
+ const link = parsoidDoc.createElement('a');
755
+ link.setAttribute('class', 'external');
756
+ link.setAttribute('href', videoId ? `https://www.youtube.com/watch?v=${videoId}` : originalUrl);
757
+ if (!videoId) {
758
+ link.textContent = 'View this content externally';
759
+ link.setAttribute('aria-label', 'View this content externally');
760
+ return link;
761
+ }
762
+ const thumbnail = parsoidDoc.createElement('img');
763
+ this.copyNodeAttributes(iframe, thumbnail);
764
+ // Set the online thumbnail URL here; treatMedias will process this
765
+ // <img> tag later to download the image and rewrite src to a local path in the ZIM.
766
+ thumbnail.setAttribute('src', `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`);
767
+ thumbnail.setAttribute('alt', 'Video thumbnail');
768
+ thumbnail.setAttribute('loading', 'lazy');
769
+ link.appendChild(thumbnail);
770
+ return link;
771
+ }
495
772
  removeCitations(parsoidDoc) {
496
773
  // Remove all citation-related material
497
774
  const sups = Array.from(parsoidDoc.getElementsByTagName('sup'));
@@ -600,8 +877,8 @@ export class Renderer {
600
877
  node.textContent = '🌍';
601
878
  }
602
879
  }
603
- applyOtherTreatments(parsoidDoc, dump, articleId) {
604
- this.removeIframeTags(parsoidDoc);
880
+ async applyOtherTreatments(parsoidDoc, dump, pagePath) {
881
+ this.processIframeTags(parsoidDoc);
605
882
  if (dump.nodet) {
606
883
  this.removeCitations(parsoidDoc);
607
884
  }
@@ -632,7 +909,7 @@ export class Renderer {
632
909
  // We use MediaWiki.baseUrl which is an approximation but it is deemed sufficient because
633
910
  // all non-absolute URL found in inline CSS are expected to be relative to the root, not to
634
911
  // current web URL which is "moving" (could use something like /w/index.php?title=... or /wiki/...)
635
- style.textContent = processStylesheetContent(MediaWiki.baseUrl.toString(), '', style.textContent, articleId);
912
+ style.textContent = await processStylesheetContent(MediaWiki.baseUrl.toString(), '', style.textContent, pagePath);
636
913
  }
637
914
  /* Remove element with id in the blacklist */
638
915
  filtersConfig.idBlackList.forEach((id) => {
@@ -642,11 +919,13 @@ export class Renderer {
642
919
  }
643
920
  });
644
921
  /*
645
- * Because of CSP, some ZIM reader environments do not allow inline JS. See issues/2096.
922
+ * We can't trust the content of inline scripts, only keep when all JavaScript is allowed
646
923
  */
647
- const scripts = Array.from(parsoidDoc.getElementsByTagName('script'));
648
- for (const script of scripts) {
649
- script.parentNode.removeChild(script);
924
+ if (!Downloader.trustedJs || Downloader.trustedJs.length) {
925
+ const scripts = Array.from(parsoidDoc.getElementsByTagName('script'));
926
+ for (const script of scripts) {
927
+ script.parentNode.removeChild(script);
928
+ }
650
929
  }
651
930
  /* Force display of element with that CSS class */
652
931
  filtersConfig.cssClassDisplayList.map((classname) => {
@@ -674,14 +953,14 @@ export class Renderer {
674
953
  return parsoidDoc;
675
954
  }
676
955
  /**
677
- * Add an H1 tag with page title on top of article except main page
956
+ * Add an H1 tag with page title on top of page except main page
678
957
  */
679
- injectH1TitleToHtml(content, articleDetail) {
958
+ injectH1TitleToHtml(content, pageDetail) {
680
959
  const doc = domino.createDocument(content);
681
960
  const header = doc.createElement('h1');
682
- if (articleDetail?.title) {
683
- header.appendChild(doc.createTextNode(articleDetail.title));
684
- header.classList.add('article-header');
961
+ if (pageDetail?.title) {
962
+ header.appendChild(doc.createTextNode(pageDetail.title));
963
+ header.classList.add('page-header');
685
964
  const target = doc.querySelector('body.mw-body-content');
686
965
  if (target) {
687
966
  target.insertAdjacentElement('afterbegin', header);