validate-presenter-mode.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. #!/usr/bin/env node
  2. import { readFileSync } from 'node:fs';
  3. import vm from 'node:vm';
  4. const file=process.argv[2];
  5. const targetArg=process.argv.find(x=>x.startsWith('--target-minutes='));
  6. const targetIndex=process.argv.indexOf('--target-minutes');
  7. const targetMinutes=Number(targetArg?.split('=')[1]??(targetIndex>=0?process.argv[targetIndex+1]:NaN));
  8. if(!file){
  9. console.error('Usage: node scripts/validate-presenter-mode.mjs <index.html> [--target-minutes 30]');
  10. process.exit(2);
  11. }
  12. const html=readFileSync(file,'utf8');
  13. const source=html.replace(/<!--[\s\S]*?-->/g,'');
  14. const errors=[];
  15. const warnings=[];
  16. function extractArray(name){
  17. const marker=new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
  18. if(!marker)return null;
  19. const start=source.indexOf('[',marker.index+marker[0].length);
  20. if(start<0)return null;
  21. let depth=0,quote='',escaped=false,lineComment=false,blockComment=false;
  22. for(let i=start;i<source.length;i++){
  23. const ch=source[i],next=source[i+1];
  24. if(lineComment){if(ch==='\n')lineComment=false;continue;}
  25. if(blockComment){if(ch==='*'&&next==='/'){blockComment=false;i++;}continue;}
  26. if(quote){if(escaped){escaped=false;continue;}if(ch==='\\'){escaped=true;continue;}if(ch===quote)quote='';continue;}
  27. if(ch==='/'&&next==='/'){lineComment=true;i++;continue;}
  28. if(ch==='/'&&next==='*'){blockComment=true;i++;continue;}
  29. if(ch==='"'||ch==="'"||ch==='`'){quote=ch;continue;}
  30. if(ch==='[')depth++;
  31. if(ch===']'&&--depth===0)return source.slice(start,i+1);
  32. }
  33. return null;
  34. }
  35. const slideTags=[...source.matchAll(/<section\b(?=[^>]*\bclass="[^"]*\bslide\b[^"]*")[^>]*>/g)].map(m=>m[0]);
  36. const slideIds=slideTags.map((tag,i)=>tag.match(/\bdata-slide-id="([^"]+)"/)?.[1]||'');
  37. if(!slideIds.length)warnings.push('No slides found; only the reusable presenter runtime can be validated.');
  38. slideIds.forEach((id,i)=>{
  39. if(!id)errors.push(`Slide ${i+1}: missing data-slide-id.`);
  40. else if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))errors.push(`Slide ${i+1}: data-slide-id="${id}" must be a lowercase semantic slug.`);
  41. });
  42. const duplicates=slideIds.filter((id,i)=>id&&slideIds.indexOf(id)!==i);
  43. if(duplicates.length)errors.push(`Duplicate data-slide-id: ${[...new Set(duplicates)].join(', ')}`);
  44. let speakerNotes=[];
  45. const arraySource=extractArray('SPEAKER_NOTES');
  46. if(!arraySource){
  47. errors.push('Missing const SPEAKER_NOTES = [...].');
  48. }else{
  49. try{speakerNotes=vm.runInNewContext(`(${arraySource})`,Object.create(null),{timeout:1000});}
  50. catch(error){errors.push(`SPEAKER_NOTES cannot be parsed: ${error.message}`);}
  51. }
  52. if(!Array.isArray(speakerNotes))errors.push('SPEAKER_NOTES must be an array.');
  53. else{
  54. if(speakerNotes.length!==slideIds.length)errors.push(`Notes/slides mismatch: ${speakerNotes.length} notes for ${slideIds.length} slides.`);
  55. const noteIds=speakerNotes.map(n=>n?.id);
  56. const noteDupes=noteIds.filter((id,i)=>id&&noteIds.indexOf(id)!==i);
  57. if(noteDupes.length)errors.push(`Duplicate speaker-note id: ${[...new Set(noteDupes)].join(', ')}`);
  58. speakerNotes.forEach((note,i)=>{
  59. if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;}
  60. for(const field of ['id','title','purpose','transition'])if(typeof note[field]!=='string'||!note[field].trim())errors.push(`Note ${i+1}: ${field} is required.`);
  61. if(note.minutes!==undefined&&note.minutes!==null&&note.minutes!==''&&(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0))errors.push(`Note ${i+1}: minutes must be > 0 when provided.`);
  62. if(note.autoAdvanceSeconds!==undefined&&note.autoAdvanceSeconds!==null&&note.autoAdvanceSeconds!==''&&(!Number.isFinite(Number(note.autoAdvanceSeconds))||Number(note.autoAdvanceSeconds)<=0))errors.push(`Note ${i+1}: autoAdvanceSeconds must be > 0 when provided.`);
  63. if(note.section!==undefined&&(typeof note.section!=='string'||!note.section.trim()))errors.push(`Note ${i+1}: section must be non-empty text when provided.`);
  64. for(const field of ['cue','interaction','delivery','advance','fallback','pronunciation']){
  65. const value=note[field];
  66. if(value===undefined||value===null||value==='')continue;
  67. const valid=typeof value==='string'?Boolean(value.trim()):Array.isArray(value)&&value.length>0&&value.every(x=>typeof x==='string'&&x.trim());
  68. if(!valid)errors.push(`Note ${i+1}: ${field} must be non-empty text or an array of non-empty text.`);
  69. }
  70. if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
  71. else{
  72. if(note.talk.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
  73. if(note.talk.length<3||note.talk.length>5)warnings.push(`Note ${i+1} (${note.id}): talk has ${note.talk.length} items; 3-5 is the default.`);
  74. }
  75. if(slideIds[i]&&note.id!==slideIds[i])errors.push(`Position ${i+1}: note id "${note.id}" does not match slide id "${slideIds[i]}".`);
  76. });
  77. }
  78. const runtimeChecks=[
  79. ['right-bottom presenter entry','id="ppt-presenter-btn"'],
  80. ['presenter shell','id="ppt-presenter"'],
  81. ['16:9 preview viewport','class="ppt-frame-viewport"'],
  82. ['proportional preview fitter','fitPresenterFrames'],
  83. ['overview control','id="ppt-grid"'],
  84. ['embedded presenter overview','id="ppt-presenter-overview"'],
  85. ['first-page control','id="ppt-first"'],
  86. ['last/restart control','id="ppt-last"'],
  87. ['audience status','id="ppt-sync"'],
  88. ['audience recovery','id="ppt-reopen"'],
  89. ['audience shutdown handling','showAudienceEnded'],
  90. ['direct window sync','postMessage('],
  91. ['BroadcastChannel fallback','BroadcastChannel'],
  92. ['storage fallback','__guizangPptSync'],
  93. ['per-slide timer','id="ppt-slide-clock"'],
  94. ['clear timer status','id="ppt-slide-status"'],
  95. ['rehearsal mode','id="ppt-rehearsal"'],
  96. ['auto advance','id="ppt-auto-toggle"'],
  97. ['laser and circle tools','id="ppt-presenter-ink"'],
  98. ['audience annotation layer','id="ppt-audience-ink"'],
  99. ['black, white, and freeze controls','setScreenMode'],
  100. ['preflight checks','id="ppt-preflight"'],
  101. ['layout presets','data-layout-choice'],
  102. ['capsule auto-advance switch','class="ppt-switch"'],
  103. ['styled interval stepper','class="ppt-stepper"'],
  104. ['reload-free preview navigation','preview-goto'],
  105. ['shortcut help','openShortcuts'],
  106. ];
  107. for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
  108. if(!/\.ppt-preview-stack\s*\{[^}]*grid-template-rows\s*:/s.test(html))errors.push('Presenter preview stack must define vertical grid rows.');
  109. if(!/\.ppt-frame\s*\{[^}]*aspect-ratio\s*:\s*16\s*\/\s*9/s.test(html))errors.push('Presenter preview frames must declare a 16:9 aspect ratio.');
  110. if(!/fitPresenterFrames[\s\S]*?16\s*\/\s*9/.test(html))errors.push('Presenter preview fitter must preserve the 16:9 ratio.');
  111. if(!/id="ppt-timer-toggle"[^>]*>开始计时<\/button>/.test(html))errors.push('Presenter timer must use the explicit label "开始计时".');
  112. if(!/id="ppt-timer-reset"[^>]*>重置计时<\/button>/.test(html))errors.push('Presenter timer reset must use the explicit label "重置计时".');
  113. if(!html.includes('继续计时'))errors.push('Presenter timer resume state must use the explicit label "继续计时".');
  114. if(/\.ppt-preview-stack\{[^}]*grid-template-columns/.test(html))errors.push('Presenter previews must stack vertically; remove grid-template-columns from .ppt-preview-stack.');
  115. const timedNotes=speakerNotes.filter(n=>Number.isFinite(Number(n?.minutes))&&Number(n.minutes)>0);
  116. const planned=timedNotes.reduce((sum,n)=>sum+Number(n.minutes),0);
  117. const completePlan=speakerNotes.length>0&&timedNotes.length===speakerNotes.length;
  118. if(timedNotes.length&&!completePlan)warnings.push(`Timing is partial: ${timedNotes.length} of ${speakerNotes.length} notes provide minutes; total-budget checks are skipped.`);
  119. if(completePlan&&Number.isFinite(targetMinutes)&&targetMinutes>0&&planned>targetMinutes*.9)errors.push(`Planned ${planned.toFixed(1)} min exceeds the 90% speaking budget (${(targetMinutes*.9).toFixed(1)} of ${targetMinutes} min).`);
  120. if(completePlan&&Number.isFinite(targetMinutes)&&targetMinutes>0&&planned<targetMinutes*.55)warnings.push(`Planned ${planned.toFixed(1)} min is below 55% of the requested ${targetMinutes} min; confirm the deck is not under-scripted.`);
  121. warnings.forEach(x=>console.warn(`WARN ${x}`));
  122. errors.forEach(x=>console.error(`ERROR ${x}`));
  123. console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${completePlan?planned.toFixed(1):'partial / —'} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
  124. process.exit(errors.length?1:0);