diff --git a/manifest.json b/manifest.json
index 93d7d13..04d2dfb 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"description": "__MSG_extDescription__",
- "version": "1.0.1",
+ "version": "2.0.0",
"minimum_chrome_version": "120",
"author": "SkillBridge Contributors",
"homepage_url": "https://github.com/heznpc/skillbridge",
diff --git a/package.json b/package.json
index 668e324..46dcb0d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "skillbridge",
- "version": "1.0.1",
+ "version": "2.0.0",
"private": true,
"scripts": {
"test": "jest --verbose",
diff --git a/src/content/content.css b/src/content/content.css
index 9f7a727..22f36d1 100644
--- a/src/content/content.css
+++ b/src/content/content.css
@@ -2044,3 +2044,39 @@ html.si18n-dark .si18n-shortcut-key {
color: #aaa;
box-shadow: 0 1px 0 #333;
}
+
+/* ============================================================
+ EXAM MODE BANNER
+ ============================================================ */
+
+.si18n-exam-banner {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 10001;
+ background: #FFF3CD;
+ color: #856404;
+ text-align: center;
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ border-bottom: 1px solid #FFEEBA;
+ transform: translateY(-100%);
+ transition: transform 0.3s ease;
+}
+
+.si18n-exam-banner.visible {
+ transform: translateY(0);
+}
+
+html.si18n-dark .si18n-exam-banner {
+ background: #3D3200;
+ color: #FFD54F;
+ border-bottom-color: #5C4B00;
+}
+
+.si18n-exam-warning {
+ border-left: 3px solid #FFC107 !important;
+ background: rgba(255, 193, 7, 0.08) !important;
+}
diff --git a/src/content/content.js b/src/content/content.js
index 31a684f..8d3dc7c 100644
--- a/src/content/content.js
+++ b/src/content/content.js
@@ -45,6 +45,7 @@
let translator = null;
let subtitleManager = null;
let currentLang = 'en';
+ let isExamPage = false;
let isReady = false;
let sidebarVisible = false;
let originalTexts = new Map();
@@ -72,12 +73,37 @@
// (header-controls.js, text-selection.js, sidebar-chat.js)
// ============================================================
+ // ============================================================
+ // EXAM PAGE DETECTION
+ // ============================================================
+
+ function detectExamPage() {
+ const url = location.href;
+ if (EXAM_URL_PATTERNS.some(p => p.test(url))) return true;
+ // DOM-based detection: check for quiz forms or answer option containers
+ if (document.querySelector(SKILLJAR_SELECTORS.quizForm)) return true;
+ if (document.querySelector(SKILLJAR_SELECTORS.answerOption)) return true;
+ return false;
+ }
+
+ function showExamBanner() {
+ if (document.getElementById('si18n-exam-banner')) return;
+ const banner = document.createElement('div');
+ banner.id = 'si18n-exam-banner';
+ banner.className = 'si18n-exam-banner';
+ banner.setAttribute('role', 'alert');
+ banner.textContent = t(EXAM_BANNER_LABELS);
+ document.body.appendChild(banner);
+ requestAnimationFrame(() => banner.classList.add('visible'));
+ }
+
window._sb = {
get currentLang() { return currentLang; },
set currentLang(v) { currentLang = v; },
get sidebarVisible() { return sidebarVisible; },
set sidebarVisible(v) { sidebarVisible = v; },
get translator() { return translator; },
+ get isExamPage() { return isExamPage; },
t,
escapeHtml,
switchLanguage,
@@ -217,6 +243,7 @@
const stored = await chrome.storage.local.get(['targetLanguage', 'autoTranslate', 'welcomeShown', 'darkMode']);
if (stored.darkMode) document.documentElement.classList.add('si18n-dark');
currentLang = stored.targetLanguage || 'en';
+ isExamPage = detectExamPage();
translator = new SkilljarTranslator();
@@ -349,6 +376,9 @@
function applyStaticTranslations(targetLang) {
buildProtectedTermsMap(targetLang);
updateLangClass(targetLang);
+ // Re-detect exam page (DOM may have loaded since init)
+ if (!isExamPage) isExamPage = detectExamPage();
+ if (isExamPage && targetLang !== 'en') showExamBanner();
const elements = getTranslatableElements();
if (elements.length === 0) return;
@@ -671,8 +701,12 @@
}
function getTranslatableElements() {
+ const examSkip = isExamPage ? EXAM_SKIP_SELECTORS.join(', ') : null;
return Array.from(document.querySelectorAll(TRANSLATABLE_SELECTOR)).filter(el => {
if (el.closest(EXCLUDE_SELECTOR)) return false;
+ // On exam pages, skip answer choice elements
+ if (examSkip && el.matches(examSkip)) return false;
+ if (examSkip && el.closest(examSkip)) return false;
const parent = el.parentElement;
if (parent && parent.matches && parent.matches(TRANSLATABLE_SELECTOR) &&
!parent.closest(EXCLUDE_SELECTOR)) {
@@ -925,6 +959,9 @@ RULES:
function getPageContext() {
const title = document.querySelector(`h1, h2, ${SKILLJAR_SELECTORS.courseTitle}`)?.textContent || document.title || '';
+ if (isExamPage) {
+ return `Certification Exam: ${title}. Page type: exam/assessment. DO NOT help with answers.`;
+ }
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4'))
.map(h => h.textContent.trim())
.slice(0, 5)
diff --git a/src/content/sidebar-chat.js b/src/content/sidebar-chat.js
index a2a0dcf..13b63a2 100644
--- a/src/content/sidebar-chat.js
+++ b/src/content/sidebar-chat.js
@@ -149,6 +149,18 @@
const messages = document.getElementById('si18n-chat-messages');
const text = input.value.trim();
if (!text) return;
+
+ // Exam mode guard — warn user, add system instruction to prevent answer leaking
+ if (sb.isExamPage) {
+ const warningMsg = sb.t(TUTOR_EXAM_LABELS);
+ messages.insertAdjacentHTML('beforeend', `
+
+
AI
+
${sb.escapeHtml(warningMsg)}
+
+ `);
+ }
+
isSending = true;
const quoteEl = document.querySelector('.si18n-chat-quote');
diff --git a/src/data/de.json b/src/data/de.json
index d772bdf..b65897d 100644
--- a/src/data/de.json
+++ b/src/data/de.json
@@ -653,5 +653,23 @@
"hooks": [
"Haken"
]
+ },
+ "exam_ui": {
+ "Pass": "Bestanden",
+ "Fail": "Nicht bestanden",
+ "Passing score": "Mindestpunktzahl",
+ "Your score": "Ihre Punktzahl",
+ "Retake": "Erneut versuchen",
+ "Attempt": "Versuch",
+ "Attempts remaining": "Verbleibende Versuche",
+ "Time remaining": "Verbleibende Zeit",
+ "Next question": "Nächste Frage",
+ "Previous question": "Vorherige Frage",
+ "Review answers": "Antworten überprüfen",
+ "Submit exam": "Prüfung abgeben",
+ "Exam complete": "Prüfung abgeschlossen",
+ "You passed!": "Herzlichen Glückwunsch, Sie haben bestanden!",
+ "You did not pass": "Sie haben nicht bestanden",
+ "Question": "Frage"
}
}
\ No newline at end of file
diff --git a/src/data/es.json b/src/data/es.json
index 3158132..2c76836 100644
--- a/src/data/es.json
+++ b/src/data/es.json
@@ -653,5 +653,23 @@
"hooks": [
"ganchos"
]
+ },
+ "exam_ui": {
+ "Pass": "Aprobado",
+ "Fail": "Reprobado",
+ "Passing score": "Puntuación para aprobar",
+ "Your score": "Tu puntuación",
+ "Retake": "Volver a intentar",
+ "Attempt": "Intento",
+ "Attempts remaining": "Intentos restantes",
+ "Time remaining": "Tiempo restante",
+ "Next question": "Siguiente pregunta",
+ "Previous question": "Pregunta anterior",
+ "Review answers": "Revisar respuestas",
+ "Submit exam": "Enviar examen",
+ "Exam complete": "Examen completado",
+ "You passed!": "¡Felicidades, aprobaste!",
+ "You did not pass": "No aprobaste",
+ "Question": "Pregunta"
}
}
\ No newline at end of file
diff --git a/src/data/fr.json b/src/data/fr.json
index 431fcc0..c791230 100644
--- a/src/data/fr.json
+++ b/src/data/fr.json
@@ -653,5 +653,23 @@
"hooks": [
"crochets"
]
+ },
+ "exam_ui": {
+ "Pass": "Réussi",
+ "Fail": "Échoué",
+ "Passing score": "Note de passage",
+ "Your score": "Votre score",
+ "Retake": "Repasser",
+ "Attempt": "Tentative",
+ "Attempts remaining": "Tentatives restantes",
+ "Time remaining": "Temps restant",
+ "Next question": "Question suivante",
+ "Previous question": "Question précédente",
+ "Review answers": "Réviser les réponses",
+ "Submit exam": "Soumettre l'examen",
+ "Exam complete": "Examen terminé",
+ "You passed!": "Félicitations, vous avez réussi !",
+ "You did not pass": "Vous n'avez pas réussi",
+ "Question": "Question"
}
}
\ No newline at end of file
diff --git a/src/data/ja.json b/src/data/ja.json
index 6d96bf5..3a5ce02 100644
--- a/src/data/ja.json
+++ b/src/data/ja.json
@@ -653,5 +653,23 @@
"hooks": [
"フック"
]
+ },
+ "exam_ui": {
+ "Pass": "合格",
+ "Fail": "不合格",
+ "Passing score": "合格点",
+ "Your score": "あなたのスコア",
+ "Retake": "再受験",
+ "Attempt": "受験回",
+ "Attempts remaining": "残りの受験回数",
+ "Time remaining": "残り時間",
+ "Next question": "次の問題",
+ "Previous question": "前の問題",
+ "Review answers": "解答を確認",
+ "Submit exam": "試験を提出",
+ "Exam complete": "試験完了",
+ "You passed!": "おめでとうございます、合格です!",
+ "You did not pass": "不合格です",
+ "Question": "問題"
}
}
\ No newline at end of file
diff --git a/src/data/ko.json b/src/data/ko.json
index 7be2ec7..3e4a1ac 100644
--- a/src/data/ko.json
+++ b/src/data/ko.json
@@ -643,5 +643,23 @@
"subagent": ["하위 에이전트", "서브에이전트"],
"hook": ["후크"],
"hooks": ["후크들", "후크"]
+ },
+ "exam_ui": {
+ "Pass": "합격",
+ "Fail": "불합격",
+ "Passing score": "합격 점수",
+ "Your score": "내 점수",
+ "Retake": "재응시",
+ "Attempt": "시도",
+ "Attempts remaining": "남은 시도 횟수",
+ "Time remaining": "남은 시간",
+ "Next question": "다음 문제",
+ "Previous question": "이전 문제",
+ "Review answers": "답안 검토",
+ "Submit exam": "시험 제출",
+ "Exam complete": "시험 완료",
+ "You passed!": "축하합니다, 합격하셨습니다!",
+ "You did not pass": "불합격하셨습니다",
+ "Question": "문제"
}
}
\ No newline at end of file
diff --git a/src/data/zh-CN.json b/src/data/zh-CN.json
index 4166662..58a1563 100644
--- a/src/data/zh-CN.json
+++ b/src/data/zh-CN.json
@@ -656,5 +656,23 @@
"钩子",
"挂钩"
]
+ },
+ "exam_ui": {
+ "Pass": "通过",
+ "Fail": "未通过",
+ "Passing score": "及格分数",
+ "Your score": "您的分数",
+ "Retake": "重新考试",
+ "Attempt": "尝试",
+ "Attempts remaining": "剩余尝试次数",
+ "Time remaining": "剩余时间",
+ "Next question": "下一题",
+ "Previous question": "上一题",
+ "Review answers": "查看答案",
+ "Submit exam": "提交考试",
+ "Exam complete": "考试完成",
+ "You passed!": "恭喜您,考试通过!",
+ "You did not pass": "您未通过考试",
+ "Question": "题目"
}
}
\ No newline at end of file
diff --git a/src/lib/constants.js b/src/lib/constants.js
index bf3c032..8dce1a0 100644
--- a/src/lib/constants.js
+++ b/src/lib/constants.js
@@ -19,6 +19,49 @@ const DEFAULT_PROTECTED_TERMS = 'API, SDK, Claude, Anthropic, Claude Code, Enter
// YouTube InnerTube client version — update periodically as needed
const YOUTUBE_CLIENT_VERSION = '2.20240101.00.00';
+// ==================== EXAM / ASSESSMENT ====================
+
+const EXAM_URL_PATTERNS = [
+ /\/quiz\b/i,
+ /\/exam\b/i,
+ /\/assessment\b/i,
+ /\/certification\b/i,
+ /[?&]type=quiz/i,
+ /[?&]type=exam/i,
+];
+
+// Elements whose text should NOT be translated on exam pages
+// (answer choices, form inputs — translating these could alter meaning)
+const EXAM_SKIP_SELECTORS = [
+ 'input[type="radio"] + label',
+ 'input[type="checkbox"] + label',
+ `${SKILLJAR_SELECTORS.answerOption}`,
+ `${SKILLJAR_SELECTORS.answerLabel}`,
+ `${SKILLJAR_SELECTORS.quizForm} label`,
+ `${SKILLJAR_SELECTORS.quizForm} .option`,
+ `${SKILLJAR_SELECTORS.quizForm} li`,
+];
+
+const EXAM_BANNER_LABELS = {
+ 'en': 'Exam mode — answer choices are not translated to preserve accuracy.',
+ 'ko': '시험 모드 — 정확성을 위해 답안 선택지는 번역되지 않습니다.',
+ 'ja': '試験モード — 正確性のため、回答選択肢は翻訳されません。',
+ 'zh-CN': '考试模式 — 为确保准确性,答案选项不会被翻译。',
+ 'es': 'Modo examen — las opciones de respuesta no se traducen para mayor precisión.',
+ 'fr': 'Mode examen — les choix de réponse ne sont pas traduits pour préserver la précision.',
+ 'de': 'Prüfungsmodus — Antwortmöglichkeiten werden nicht übersetzt, um die Genauigkeit zu wahren.',
+};
+
+const TUTOR_EXAM_LABELS = {
+ 'en': "I can't help with exam answers directly, but I can explain concepts after you submit.",
+ 'ko': '시험 답안은 직접 도와드릴 수 없지만, 제출 후 개념 설명은 가능합니다.',
+ 'ja': '試験の回答は直接お手伝いできませんが、提出後にコンセプトを説明できます。',
+ 'zh-CN': '我不能直接帮助回答考试题目,但提交后可以解释相关概念。',
+ 'es': 'No puedo ayudar directamente con las respuestas del examen, pero puedo explicar conceptos después de enviar.',
+ 'fr': "Je ne peux pas aider directement avec les réponses d'examen, mais je peux expliquer les concepts après soumission.",
+ 'de': 'Ich kann nicht direkt bei Prüfungsantworten helfen, aber nach der Abgabe kann ich Konzepte erklären.',
+};
+
// ==================== THRESHOLDS ====================
const SKILLBRIDGE_THRESHOLDS = {
diff --git a/src/lib/selectors.js b/src/lib/selectors.js
index 3c0aa22..2b6550e 100644
--- a/src/lib/selectors.js
+++ b/src/lib/selectors.js
@@ -39,4 +39,11 @@ const SKILLJAR_SELECTORS = {
// FAQ
faqTitle: '.faq-title',
faqPost: '.faq-post',
+
+ // Quiz / Assessment / Certification
+ quizForm: '.quiz-form, .assessment-form, form[class*="quiz"], form[class*="assessment"]',
+ answerOption: '.answer-option, .answer-choice, .quiz-option, .assessment-option',
+ answerLabel: 'label[class*="answer"], label[class*="option"], label[class*="choice"]',
+ quizResult: '.quiz-result, .assessment-result, .quiz-score, .score-display',
+ certificateSection: '.certificate-section, .certificate-panel, .certificate-container',
};
diff --git a/tests/constants.test.js b/tests/constants.test.js
index d23b10f..8b523f1 100644
--- a/tests/constants.test.js
+++ b/tests/constants.test.js
@@ -8,17 +8,22 @@
const fs = require('fs');
const path = require('path');
+const selectorsSrc = fs.readFileSync(
+ path.join(__dirname, '..', 'src', 'lib', 'selectors.js'), 'utf8'
+);
const constantsSrc = fs.readFileSync(
path.join(__dirname, '..', 'src', 'lib', 'constants.js'), 'utf8'
);
-// Eval constants into a returned object so they're accessible in test scope
-const constants = new Function(`${constantsSrc}; return {
+// Eval selectors first (constants.js references SKILLJAR_SELECTORS), then constants
+const constants = new Function(`${selectorsSrc}\n${constantsSrc}; return {
SKILLBRIDGE_MODELS, SKILLBRIDGE_THRESHOLDS, SKILLBRIDGE_DELAYS, SKILLBRIDGE_LIMITS,
PREMIUM_LANGUAGES, AVAILABLE_LANGUAGES, AVAILABLE_LANGUAGE_CODES,
SUPPORTED_LANGUAGE_MAP, POPUP_LABELS, DEFAULT_PROTECTED_TERMS,
YOUTUBE_CLIENT_VERSION, SKILLBRIDGE_MODEL_LABELS,
SHORTCUT_LABELS, SHORTCUT_DESCRIPTIONS,
+ EXAM_URL_PATTERNS, EXAM_SKIP_SELECTORS, EXAM_BANNER_LABELS, TUTOR_EXAM_LABELS,
+ SKILLJAR_SELECTORS,
};`)();
const {
diff --git a/tests/translator.test.js b/tests/translator.test.js
index f72b354..2c8a301 100644
--- a/tests/translator.test.js
+++ b/tests/translator.test.js
@@ -17,7 +17,10 @@ global.window = { addEventListener: () => {} };
const fs = require('fs');
const path = require('path');
-// Load shared constants first (translator.js depends on them)
+// Load selectors + constants first (constants.js depends on selectors, translator.js depends on constants)
+const selectorsSrc = fs.readFileSync(
+ path.join(__dirname, '..', 'src', 'lib', 'selectors.js'), 'utf8'
+);
const constantsSrc = fs.readFileSync(
path.join(__dirname, '..', 'src', 'lib', 'constants.js'), 'utf8'
);
@@ -25,12 +28,13 @@ const src = fs.readFileSync(
path.join(__dirname, '..', 'src', 'lib', 'translator.js'), 'utf8'
);
-// Combine constants + translator in a single eval so constants are in scope
+// Combine selectors + constants + translator in a single eval so all are in scope
let SkilljarTranslator;
try {
- const combined = `(function() { ${constantsSrc}; ${src}; return SkilljarTranslator; })()`;
+ const combined = `(function() { ${selectorsSrc}; ${constantsSrc}; ${src}; return SkilljarTranslator; })()`;
SkilljarTranslator = eval(combined);
} catch (e) {
+ eval(selectorsSrc);
eval(constantsSrc);
eval(src);
SkilljarTranslator = global.SkilljarTranslator;