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>
弱点集中特訓
</div>
<button class="btn-sm" @click="exitWeakDrill()">
<i data-lucide="arrow-left" style="width:11px;height:11px"></i>
<button class="btn-sm" type="button" @click="exitWeakDrill()" aria-label="弱点特訓を終了する">
<i data-lucide="x" style="width:11px;height:11px"></i>
特訓をやめ
</button>
</div>

View File

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

2
sw.js
View File

@ -1,5 +1,5 @@
// 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'];
self.addEventListener('install', e => {