chore: sanitize stored quiz exp, kp delimiter outside tags, weak drill exit label, plainText helper

Made-with: Cursor
This commit is contained in:
posimai 2026-04-19 16:25:46 +09:00
parent 0f2ae7f332
commit 16547cf0ad
3 changed files with 38 additions and 13 deletions

View File

@ -507,9 +507,9 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
<i data-lucide="zap" style="width:13px;height:13px"></i> <i data-lucide="zap" style="width:13px;height:13px"></i>
弱点集中特訓 弱点集中特訓
</div> </div>
<button class="btn-sm" @click="exitWeakDrill()"> <button class="btn-sm" type="button" @click="exitWeakDrill()" aria-label="弱点特訓を終了する">
<i data-lucide="arrow-left" style="width:11px;height:11px"></i> <i data-lucide="x" style="width:11px;height:11px"></i>
特訓をやめ
</button> </button>
</div> </div>

View File

@ -1,6 +1,10 @@
/** /**
* Alpine アプリ本体単元データは ./data/categories.js ./data/drills.js * Alpine アプリ本体単元データは ./data/categories.js ./data/drills.js
* 表示系 HTML safeHtml() 経由DOMPurifyでサニタイズすること *
* HTML 方針:
* - マークアップを描画する箇所は x-html="safeHtml(...)" のみ
* - タグを出さない一行表示には plainText(...)サニタイズ後に textContent
* - 状態に保持する解説文字列は保存時に sanitizeStoredHtml で正規化
*/ */
import { DRILLS } from './data/drills.js'; import { DRILLS } from './data/drills.js';
import { CATEGORIES } from './data/categories.js'; import { CATEGORIES } from './data/categories.js';
@ -19,11 +23,27 @@ function sanitizeTrustedHtml(dirty) {
}); });
} }
/** localStorage / 画面状態に載せる HTML 断片の正規化(表示前と同じルール) */
function sanitizeStoredHtml(html) {
return sanitizeTrustedHtml(html);
}
/** サニタイズ済み HTML からプレーン文字列x-text 向け) */
function plainTextFromSanitizedHtml(html) {
if (html == null || html === '') return '';
const d = document.createElement('div');
d.innerHTML = sanitizeTrustedHtml(String(html));
return (d.textContent || '').replace(/\s+/g, ' ').trim();
}
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('bokiApp', () => ({ Alpine.data('bokiApp', () => ({
safeHtml(s) { safeHtml(s) {
return sanitizeTrustedHtml(s); return sanitizeTrustedHtml(s);
}, },
plainText(s) {
return plainTextFromSanitizedHtml(s);
},
categories:[], categories:[],
currentUnit:null, currentUnit:null,
quizState:{}, quizState:{},
@ -228,7 +248,7 @@ document.addEventListener('alpine:init', () => {
answered(qi){ return !!this.quizState[qi]; }, answered(qi){ return !!this.quizState[qi]; },
doAnswer(qi,ci,correct,exp){ doAnswer(qi,ci,correct,exp){
if(this.quizState[qi]) return; if(this.quizState[qi]) return;
this.quizState[qi]={selected:ci,correct:ci===correct,exp}; this.quizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)};
this.quizState={...this.quizState}; this.quizState={...this.quizState};
// Check if all questions answered // Check if all questions answered
const total=this.currentUnit?.quiz?.length||0; const total=this.currentUnit?.quiz?.length||0;
@ -353,7 +373,7 @@ document.addEventListener('alpine:init', () => {
}, },
doWeakDrillAnswer(qi,ci,correct,exp){ doWeakDrillAnswer(qi,ci,correct,exp){
if(this.weakDrillQuizState[qi]) return; if(this.weakDrillQuizState[qi]) return;
this.weakDrillQuizState[qi]={selected:ci,correct:ci===correct,exp}; this.weakDrillQuizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)};
this.weakDrillQuizState={...this.weakDrillQuizState}; this.weakDrillQuizState={...this.weakDrillQuizState};
}, },
get weakDrillAllAnswered(){ get weakDrillAllAnswered(){
@ -431,6 +451,7 @@ document.addEventListener('alpine:init', () => {
this.resetFlashRevealState(); this.resetFlashRevealState();
} }
} else if(this.stepStep===3){ } else if(this.stepStep===3){
this.quizState={};
const drills=this.unitDrills; const drills=this.unitDrills;
if(drills.length>0){ if(drills.length>0){
this.stepStep=2; this.stepStep=2;
@ -457,12 +478,16 @@ document.addEventListener('alpine:init', () => {
}, },
_kpDelimiterIndex(kp){ _kpDelimiterIndex(kp){
if(!kp||typeof kp!=='string') return -1; if(!kp||typeof kp!=='string') return -1;
const iw=kp.indexOf(''); const ja=kp.indexOf('');
const ia=kp.indexOf(':'); if(ja>=0) return ja;
if(iw<0&&ia<0) return -1; let depth=0;
if(iw<0) return ia; for(let i=0;i<kp.length;i++){
if(ia<0) return iw; const ch=kp[i];
return Math.min(iw,ia); if(ch==='<') depth++;
else if(ch==='>') depth=Math.max(0,depth-1);
else if(ch===':'&&depth===0) return i;
}
return -1;
}, },
keypointHasFlip(idx){ keypointHasFlip(idx){
const kp=(this.currentUnit?.keypoints||[])[idx]; const kp=(this.currentUnit?.keypoints||[])[idx];

2
sw.js
View File

@ -1,5 +1,5 @@
// posimai-boki SW — stale-while-revalidate + skipWaiting // posimai-boki SW — stale-while-revalidate + skipWaiting
const CACHE = 'posimai-boki-v15'; const CACHE = 'posimai-boki-v16';
const STATIC = ['/', '/index.html', '/manifest.json', '/logo.png', '/js/app.js', '/js/data/drills.js', '/js/data/categories.js']; const STATIC = ['/', '/index.html', '/manifest.json', '/logo.png', '/js/app.js', '/js/data/drills.js', '/js/data/categories.js'];
self.addEventListener('install', e => { self.addEventListener('install', e => {