From 60007d4e6a11d33f2895a9fe26969a779da0f7be Mon Sep 17 00:00:00 2001
From: rays
Date: Wed, 14 Oct 2026 17:50:33 +0000
Subject: [PATCH] Page type-check: strict throughout, a typed $, null checks
(#236)
$ returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.
isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 8 ++-
tsconfig.json | 11 +---
web/src/admin.ts | 48 +++++++-------
web/src/dialogs.ts | 148 ++++++++++++++++++++++----------------------
web/src/events.ts | 2 +-
web/src/feedpage.ts | 20 +++---
web/src/feeds.ts | 18 +++---
web/src/gestures.ts | 2 +-
web/src/items.ts | 47 +++++++-------
web/src/login.ts | 4 +-
web/src/player.ts | 54 ++++++++--------
web/src/theme.ts | 10 +--
web/src/util.ts | 12 ++--
13 files changed, 191 insertions(+), 193 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1df60cd..8136714 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -154,9 +154,11 @@ node tests/page-smoke.js
node --test tests/page-unit.js
```
-The first type-checks `web/src`: every parameter and variable has a type, and what the API sends
-is typed in `web/src/types.ts`, which mirrors the Rust structs and changes with them. Not yet
-null-safe: `strictNullChecks` is off and `$` returns `any` (#236). The second
+The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
+type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
+with them, and a value that may be null has to be checked before use. `$` returns the element
+type it is given (`$('#nurl')`) and assumes the element is there; one that may
+not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a
diff --git a/tsconfig.json b/tsconfig.json
index 7b0a708..af7a0d3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,14 +5,9 @@
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true,
- // strict, a flag at a time (#235); strictNullChecks still to come, with a typed $ (#236).
- "strict": false,
- "noImplicitAny": true,
- "strictFunctionTypes": true,
- "strictBindCallApply": true,
- "noImplicitThis": true,
- "alwaysStrict": true,
- "useUnknownInCatchVariables": true,
+ // Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
+ // ones wired at load exist, and a lookup that may find nothing says so with ?.
+ "strict": true,
"skipLibCheck": true
},
"include": ["web/src/*.ts"]
diff --git a/web/src/admin.ts b/web/src/admin.ts
index 99dac6e..e1d64e4 100644
--- a/web/src/admin.ts
+++ b/web/src/admin.ts
@@ -56,15 +56,15 @@ async function drawServer(){
${esc(g.download_dir)}
`;
- $('#gsave').onclick = async () => {
+ $('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
- schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
- max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
- media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
- max_total_gb: Number($('#gquota').value) || 0,
- max_age_days: Number($('#gage').value) || 0,
- art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
+ schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
+ max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
+ media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
+ max_total_gb: Number($('#gquota').value) || 0,
+ max_age_days: Number($('#gage').value) || 0,
+ art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
toast('Settings saved');
}catch(e){ toast(why(e), true); }
};
@@ -101,13 +101,13 @@ async function drawDirectorySettings(){
drawDirectorySettings();
}catch(e){ toast(why(e), true); }
};
- $('#dsave').onclick = () => {
- const patch: Record = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
+ $('#dsave').onclick = () => {
+ const patch: Record = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it.
- if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
+ if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
save(patch, 'Directory settings saved');
};
- const clear = $('#dclear');
+ const clear = $('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
drawDirectoryStatus();
}
@@ -161,12 +161,12 @@ async function drawDirectoryAdmin(){
const row = b.closest('.urow');
try{
if(b.dataset.a === 'drop'){
- const name = row.dataset.name;
+ const name = row!.dataset.name!;
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
toast(`Dropped ${name}'s snapshot`);
}else if(b.dataset.a === 'unblock'){
- await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'});
+ await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
toast('Unblocked. It comes back with the next snapshot that has it');
}
drawDirectoryAdmin();
@@ -205,18 +205,18 @@ async function drawAccounts(){
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
};
for(const row of $$('#accounts [data-id]')){
- const u = users.find(x => String(x.id) === row.dataset.id);
- $('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) =>
- change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
+ const u = users.find(x => String(x.id) === row.dataset.id)!;
+ $('[data-a="admin"]', row).onchange = (e: Event) =>
+ change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'});
};
}
- $('#uadd').onclick = async () => {
+ $('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
- name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
+ name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
toast('Added'); drawAccounts();
}catch(e){ toast(why(e), true); } // keep what was typed
};
@@ -259,14 +259,14 @@ function drawLogView(){
The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes
what it captures.`;
for(const b of $$('#logtabs button')) b.onclick = () => {
- logTab = b.dataset.t;
+ logTab = b.dataset.t!;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
- $('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); };
- $('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { logFilter = e.target.value.toLowerCase(); drawLog(); };
- $('#logcopy').onclick = () => copyText(visibleLog().map(l =>
- `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
+ $('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
+ $('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
+ $('#logcopy').onclick = () => copyText(visibleLog().map(l =>
+ `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000);
}
@@ -294,7 +294,7 @@ function visibleLog(){
}
function drawLog(){
const box = $('#logbox'); if(!box) return;
- const follow = $('#logfollow')?.checked;
+ const follow = $('#logfollow')?.checked;
const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString();
diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts
index f0589fd..23f72d3 100644
--- a/web/src/dialogs.ts
+++ b/web/src/dialogs.ts
@@ -11,7 +11,7 @@ function closeModal(){
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
-$('#addFeed').onclick=()=>{
+$('#addFeed').onclick=()=>{
openModal(`
`);
- $('#oimp').onclick=async()=>{
+ $('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported.
- const pick=$('#opmlFile'), file=pick.files[0];
- const xml=file ? await file.text() : $('#opmlText').value;
+ const pick=$('#opmlFile'), file=pick.files![0];
+ const xml=file ? await file.text() : $('#opmlText').value;
const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word.
if(!/]/i.test(xml)){
@@ -734,11 +734,11 @@ async function scanAll(){
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
catch(e){ spinEnd(); throw e; }
}
-$('#scanAll').onclick=scanAll;
-$('#prefs').onclick=prefsModal;
+$('#scanAll').onclick=scanAll;
+$('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
// the proxy still vouches for them. /api/me says where, when that is the case.
-$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
+$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
@@ -750,18 +750,18 @@ api('/api/me').then(u=>{
// A browser with no theme of its own takes the one the account kept from before, once.
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
}).catch(()=>{});
-$('#feedFilter').oninput=renderFeeds;
+$('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
const row=(ev.target as Element).closest('[data-id]'); if(!row) return;
- const id=row.dataset.id;
+ const id=row.dataset.id!;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return;
ev.preventDefault(); ev.stopPropagation();
};
-$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
+$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);
diff --git a/web/src/events.ts b/web/src/events.ts
index 33ec0ea..7aa81d1 100644
--- a/web/src/events.ts
+++ b/web/src/events.ts
@@ -26,7 +26,7 @@ function connect(){
// Only the row actually downloading. Without the enclosure id this used to paint
// every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"] i`);
- if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
+ if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){
diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts
index 967393c..601dd08 100644
--- a/web/src/feedpage.ts
+++ b/web/src/feedpage.ts
@@ -3,16 +3,16 @@ function renderFeed(){
paintScanning(); // another feed's page may be the one open now
const box=$('#content');
box.classList.remove('plain');
- const v=VIEWS[S.feed];
+ const v=VIEWS[S.feed ?? ''];
if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed);
- $('#tbRemove').disabled=!f;
+ $('#tbRemove').disabled=!f;
syncTools(null);
if(!v&&!f){ box.innerHTML='
'; return; }
@@ -154,7 +154,7 @@ function renderGroup(f: Feed,kids: Feed[]){
box.appendChild(el);
}
};
- $('#kidSearch').oninput=draw;
+ $('#kidSearch').oninput=draw;
draw();
}
diff --git a/web/src/feeds.ts b/web/src/feeds.ts
index 6706cd8..5f8a829 100644
--- a/web/src/feeds.ts
+++ b/web/src/feeds.ts
@@ -19,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
- selectFeed(known ? S.feed : ':all');
+ selectFeed(known ? S.feed! : ':all');
}
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
@@ -45,9 +45,9 @@ function setScanning(id: string, on: boolean){
paintScanning();
}
function paintScanning(){
- const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)>=SPIN_AFTER;
+ const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
for(const row of $$('#feedlist .feed')){
- const id=row.dataset.id;
+ const id=row.dataset.id!;
const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
@@ -73,21 +73,21 @@ function spinEnd(done?: {only: string|null, forced: boolean}){
}
function renderFeeds(){
- const q=$('#feedFilter').value.trim().toLowerCase();
+ const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop;
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
const a=document.activeElement, was=a&&a.closest&&a.closest('#feedlist [data-id]');
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
const done=()=>{
list.scrollTop=top;
- const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
- if(row) (back[1]&&$('.chev',row)||row).focus();
+ const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
+ if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
};
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
// the list went blank and loaded again, once a frame through a check of every feed, as each
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
const drawn=new Map();
- for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); }
+ for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div');
@@ -168,7 +168,7 @@ function renderFeeds(){
done();
}
function selectFeed(id: string){
- S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
+ S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
}
@@ -185,6 +185,6 @@ function failBannerHTML(f: Feed){
}
function wireFailBanner(box: ParentNode,f: Feed){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
- const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
+ const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
}
diff --git a/web/src/gestures.ts b/web/src/gestures.ts
index c71cacf..9c8cd34 100644
--- a/web/src/gestures.ts
+++ b/web/src/gestures.ts
@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
// back button is never swallowed with it.
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
}
- else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
+ else if(list && list.scrollTop <= 0 && !VIEWS[S.feed ?? '']?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
}, {passive: false});
document.addEventListener('touchmove', ev => {
diff --git a/web/src/items.ts b/web/src/items.ts
index cc9d49a..92bf112 100644
--- a/web/src/items.ts
+++ b/web/src/items.ts
@@ -14,7 +14,7 @@ async function loadEntries(append?: boolean){
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
- if(w && !(w.donex.id===S.feed), v=VIEWS[S.feed];
+ const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
@@ -78,7 +78,7 @@ function epEl(e: Entry){
num&&`${num}`,
left&&`${left}`,
others>0&&`+${others} more file${others===1?'':'s'}`,
- enc&&enc.deadair>0&&`${shorter(enc.deadair)}`,
+ enc&&enc.deadair&&enc.deadair>0&&`${shorter(enc.deadair)}`,
enc&&enc.deadair_pending&&DEAD_PENDING,
enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`,
].filter(Boolean).join('')}
@@ -97,13 +97,13 @@ function epEl(e: Entry){
${has?``:''}
`;
el.onclick=()=>selectEntry(e);
- $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
+ $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an