@@ -293,6 +293,170 @@ ${editorHtmlWithLocalMedia}
293293</html>` ;
294294} ;
295295
296+ /**
297+ * Enrich the HTML produced by the editor with semantic tags and basic a11y defaults.
298+ *
299+ * Notes:
300+ * - We work directly on the parsed Document so modifications are reflected before we zip files.
301+ * - We keep the editor inner structure but upgrade the key block types to native elements.
302+ */
303+ export const improveHtmlAccessibility = (
304+ parsedDocument : Document ,
305+ documentTitle : string ,
306+ ) => {
307+ const body = parsedDocument . body ;
308+ if ( ! body ) {
309+ return ;
310+ }
311+
312+ // 1) Headings: convert heading blocks to h1-h6 based on data-level
313+ const headingBlocks = Array . from (
314+ body . querySelectorAll < HTMLElement > ( "[data-content-type='heading']" ) ,
315+ ) ;
316+
317+ headingBlocks . forEach ( ( block ) => {
318+ const rawLevel = Number ( block . getAttribute ( 'data-level' ) ) || 1 ;
319+ const level = Math . min ( Math . max ( rawLevel , 1 ) , 6 ) ;
320+ const heading = parsedDocument . createElement ( `h${ level } ` ) ;
321+ heading . innerHTML = block . innerHTML ;
322+ block . replaceWith ( heading ) ;
323+ } ) ;
324+
325+ // 2) Lists: group consecutive list items into UL/OL with LI children
326+ const listItemSelector =
327+ "[data-content-type='bulletListItem'], [data-content-type='numberedListItem']" ;
328+ const listItems = Array . from (
329+ body . querySelectorAll < HTMLElement > ( listItemSelector ) ,
330+ ) ;
331+
332+ listItems . forEach ( ( item ) => {
333+ const parent = item . parentElement ;
334+ if ( ! parent ) {
335+ return ;
336+ }
337+
338+ const isBullet =
339+ item . getAttribute ( 'data-content-type' ) === 'bulletListItem' ;
340+ const listTag = isBullet ? 'ul' : 'ol' ;
341+
342+ // If the previous sibling is already the right list, reuse it; otherwise create a new one.
343+ let previousSibling = item . previousElementSibling ;
344+ let listContainer : HTMLElement | null = null ;
345+
346+ if ( previousSibling ?. tagName . toLowerCase ( ) === listTag ) {
347+ listContainer = previousSibling as HTMLElement ;
348+ } else {
349+ listContainer = parsedDocument . createElement ( listTag ) ;
350+ parent . insertBefore ( listContainer , item ) ;
351+ }
352+
353+ const li = parsedDocument . createElement ( 'li' ) ;
354+ li . innerHTML = item . innerHTML ;
355+ listContainer . appendChild ( li ) ;
356+ parent . removeChild ( item ) ;
357+ } ) ;
358+
359+ // 3) Quotes -> <blockquote>
360+ const quoteBlocks = Array . from (
361+ body . querySelectorAll < HTMLElement > ( "[data-content-type='quote']" ) ,
362+ ) ;
363+ quoteBlocks . forEach ( ( block ) => {
364+ const quote = parsedDocument . createElement ( 'blockquote' ) ;
365+ quote . innerHTML = block . innerHTML ;
366+ block . replaceWith ( quote ) ;
367+ } ) ;
368+
369+ // 4) Callouts -> <aside role="note">
370+ const calloutBlocks = Array . from (
371+ body . querySelectorAll < HTMLElement > ( "[data-content-type='callout']" ) ,
372+ ) ;
373+ calloutBlocks . forEach ( ( block ) => {
374+ const aside = parsedDocument . createElement ( 'aside' ) ;
375+ aside . setAttribute ( 'role' , 'note' ) ;
376+ aside . innerHTML = block . innerHTML ;
377+ block . replaceWith ( aside ) ;
378+ } ) ;
379+
380+ // 5) Checklists -> list + checkbox semantics
381+ const checkListItems = Array . from (
382+ body . querySelectorAll < HTMLElement > ( "[data-content-type='checkListItem']" ) ,
383+ ) ;
384+ checkListItems . forEach ( ( item ) => {
385+ const parent = item . parentElement ;
386+ if ( ! parent ) {
387+ return ;
388+ }
389+
390+ let previousSibling = item . previousElementSibling ;
391+ let listContainer : HTMLElement | null = null ;
392+
393+ if ( previousSibling ?. tagName . toLowerCase ( ) === 'ul' ) {
394+ listContainer = previousSibling as HTMLElement ;
395+ } else {
396+ listContainer = parsedDocument . createElement ( 'ul' ) ;
397+ listContainer . setAttribute ( 'role' , 'list' ) ;
398+ parent . insertBefore ( listContainer , item ) ;
399+ }
400+
401+ const li = parsedDocument . createElement ( 'li' ) ;
402+ li . innerHTML = item . innerHTML ;
403+
404+ // Ensure checkbox has an accessible state; fall back to aria-checked if missing.
405+ const checkbox = li . querySelector < HTMLInputElement > (
406+ "input[type='checkbox']" ,
407+ ) ;
408+ if ( checkbox && ! checkbox . hasAttribute ( 'aria-checked' ) ) {
409+ checkbox . setAttribute (
410+ 'aria-checked' ,
411+ checkbox . checked ? 'true' : 'false' ,
412+ ) ;
413+ }
414+
415+ listContainer . appendChild ( li ) ;
416+ parent . removeChild ( item ) ;
417+ } ) ;
418+
419+ // 6) Code blocks -> <pre><code>
420+ const codeBlocks = Array . from (
421+ body . querySelectorAll < HTMLElement > ( "[data-content-type='codeBlock']" ) ,
422+ ) ;
423+ codeBlocks . forEach ( ( block ) => {
424+ const pre = parsedDocument . createElement ( 'pre' ) ;
425+ const code = parsedDocument . createElement ( 'code' ) ;
426+ code . innerHTML = block . innerHTML ;
427+ pre . appendChild ( code ) ;
428+ block . replaceWith ( pre ) ;
429+ } ) ;
430+
431+ // 7) Ensure images have alt text (empty when not provided)
432+ body . querySelectorAll < HTMLImageElement > ( 'img' ) . forEach ( ( img ) => {
433+ if ( ! img . hasAttribute ( 'alt' ) ) {
434+ img . setAttribute ( 'alt' , '' ) ;
435+ }
436+ } ) ;
437+
438+ // 8) Wrap content in an article with a title landmark if none exists
439+ const existingH1 = body . querySelector ( 'h1' ) ;
440+ if ( ! existingH1 ) {
441+ const titleHeading = parsedDocument . createElement ( 'h1' ) ;
442+ titleHeading . id = 'doc-title' ;
443+ titleHeading . textContent = documentTitle ;
444+ body . insertBefore ( titleHeading , body . firstChild ) ;
445+ }
446+
447+ // If there is no article, group the body content inside one for better semantics.
448+ const hasArticle = body . querySelector ( 'article' ) ;
449+ if ( ! hasArticle ) {
450+ const article = parsedDocument . createElement ( 'article' ) ;
451+ article . setAttribute ( 'role' , 'document' ) ;
452+ article . setAttribute ( 'aria-labelledby' , 'doc-title' ) ;
453+ while ( body . firstChild ) {
454+ article . appendChild ( body . firstChild ) ;
455+ }
456+ body . appendChild ( article ) ;
457+ }
458+ } ;
459+
296460export const addMediaFilesToZip = async (
297461 parsedDocument : Document ,
298462 zip : JSZip ,
0 commit comments