100 lines
5.2 KiB
JavaScript
100 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFileSync } from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
const file=process.argv[2];
|
|
const targetArg=process.argv.find(x=>x.startsWith('--target-minutes='));
|
|
const targetIndex=process.argv.indexOf('--target-minutes');
|
|
const targetMinutes=Number(targetArg?.split('=')[1]??(targetIndex>=0?process.argv[targetIndex+1]:NaN));
|
|
|
|
if(!file){
|
|
console.error('Usage: node scripts/validate-presenter-mode.mjs <index.html> [--target-minutes 30]');
|
|
process.exit(2);
|
|
}
|
|
|
|
const html=readFileSync(file,'utf8');
|
|
const source=html.replace(/<!--[\s\S]*?-->/g,'');
|
|
const errors=[];
|
|
const warnings=[];
|
|
|
|
function extractArray(name){
|
|
const marker=new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
|
|
if(!marker)return null;
|
|
const start=source.indexOf('[',marker.index+marker[0].length);
|
|
if(start<0)return null;
|
|
let depth=0,quote='',escaped=false,lineComment=false,blockComment=false;
|
|
for(let i=start;i<source.length;i++){
|
|
const ch=source[i],next=source[i+1];
|
|
if(lineComment){if(ch==='\n')lineComment=false;continue;}
|
|
if(blockComment){if(ch==='*'&&next==='/'){blockComment=false;i++;}continue;}
|
|
if(quote){if(escaped){escaped=false;continue;}if(ch==='\\'){escaped=true;continue;}if(ch===quote)quote='';continue;}
|
|
if(ch==='/'&&next==='/'){lineComment=true;i++;continue;}
|
|
if(ch==='/'&&next==='*'){blockComment=true;i++;continue;}
|
|
if(ch==='"'||ch==="'"||ch==='`'){quote=ch;continue;}
|
|
if(ch==='[')depth++;
|
|
if(ch===']'&&--depth===0)return source.slice(start,i+1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const slideTags=[...source.matchAll(/<section\b(?=[^>]*\bclass="[^"]*\bslide\b[^"]*")[^>]*>/g)].map(m=>m[0]);
|
|
const slideIds=slideTags.map((tag,i)=>tag.match(/\bdata-slide-id="([^"]+)"/)?.[1]||'');
|
|
|
|
slideIds.forEach((id,i)=>{
|
|
if(!id)errors.push(`Slide ${i+1}: missing data-slide-id.`);
|
|
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.`);
|
|
});
|
|
const duplicates=slideIds.filter((id,i)=>id&&slideIds.indexOf(id)!==i);
|
|
if(duplicates.length)errors.push(`Duplicate data-slide-id: ${[...new Set(duplicates)].join(', ')}`);
|
|
|
|
let speakerNotes=[];
|
|
const arraySource=extractArray('SPEAKER_NOTES');
|
|
if(!arraySource){
|
|
errors.push('Missing const SPEAKER_NOTES = [...].');
|
|
}else{
|
|
try{speakerNotes=vm.runInNewContext(`(${arraySource})`,Object.create(null),{timeout:1000});}
|
|
catch(error){errors.push(`SPEAKER_NOTES cannot be parsed: ${error.message}`);}
|
|
}
|
|
|
|
if(!Array.isArray(speakerNotes))errors.push('SPEAKER_NOTES must be an array.');
|
|
else{
|
|
if(speakerNotes.length!==slideIds.length)errors.push(`Notes/slides mismatch: ${speakerNotes.length} notes for ${slideIds.length} slides.`);
|
|
const noteIds=speakerNotes.map(n=>n?.id);
|
|
const noteDupes=noteIds.filter((id,i)=>id&¬eIds.indexOf(id)!==i);
|
|
if(noteDupes.length)errors.push(`Duplicate speaker-note id: ${[...new Set(noteDupes)].join(', ')}`);
|
|
speakerNotes.forEach((note,i)=>{
|
|
if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;}
|
|
for(const field of ['id','title','purpose','transition'])if(typeof note[field]!=='string'||!note[field].trim())errors.push(`Note ${i+1}: ${field} is required.`);
|
|
if(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0)errors.push(`Note ${i+1}: minutes must be > 0.`);
|
|
if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
|
|
else{
|
|
if(note.talk.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
|
|
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.`);
|
|
}
|
|
if(slideIds[i]&¬e.id!==slideIds[i])errors.push(`Position ${i+1}: note id "${note.id}" does not match slide id "${slideIds[i]}".`);
|
|
});
|
|
}
|
|
|
|
const runtimeChecks=[
|
|
['right-bottom presenter entry','id="ppt-presenter-btn"'],
|
|
['presenter shell','id="ppt-presenter"'],
|
|
['overview control','id="ppt-grid"'],
|
|
['first-page control','id="ppt-first"'],
|
|
['last/restart control','id="ppt-last"'],
|
|
['audience status','id="ppt-sync"'],
|
|
['audience recovery','id="ppt-reopen"'],
|
|
['direct window sync','postMessage('],
|
|
['BroadcastChannel fallback','BroadcastChannel'],
|
|
['storage fallback','__guizangPptSync'],
|
|
];
|
|
for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
|
|
|
|
const planned=speakerNotes.reduce((sum,n)=>sum+(Number(n?.minutes)||0),0);
|
|
if(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).`);
|
|
if(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.`);
|
|
|
|
warnings.forEach(x=>console.warn(`WARN ${x}`));
|
|
errors.forEach(x=>console.error(`ERROR ${x}`));
|
|
console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${planned.toFixed(1)} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
|
|
process.exit(errors.length?1:0);
|