diff --git a/LICENSE b/LICENSE index 8c6c1b2..af430fe 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025-2026 SkillBridge Contributors +Copyright (c) 2025-2026 Heznpc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4daf8b8..28686f8 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ See our full [Privacy Policy](PRIVACY_POLICY.md). | AI Tutor | Claude Sonnet 4.6 via Puter.js | | Curated Dictionaries | Hand-tuned JSON (1,100+ × 12 languages) | | Translation Cache | IndexedDB | -| CJK Font Rendering | Google Fonts Noto Sans | +| CJK Font Rendering | Local system/Noto fallback stacks | > **Built with [Claude Code](https://docs.anthropic.com/en/docs/claude-code).** > This project — from architecture design and feature implementation to debugging and the demo GIF — was developed using Claude Code as an AI pair-programming partner. diff --git a/TODO.md b/TODO.md index acbf7da..98e728d 100644 --- a/TODO.md +++ b/TODO.md @@ -81,10 +81,10 @@ be treated as the real public service, not just a repo-ready release candidate. removed before relying on the CD upload path. Before live publish, set `CWS_DASHBOARD_READY_VERSION` to the manifest version only after the CWS dashboard listing/media/privacy fields are refreshed. -- [ ] **Migrate CWS CD off the v1.1 upload action.** The current pinned upload - action still uses the older Chrome Web Store upload/publish API surface. Keep - the new dashboard-ready and target-listing guards, but replace the action with - the current CWS API path before the older endpoint support window closes. +- [x] **CWS CD upload action on the current pinned path.** The workflow uses + `mnao305/chrome-extension-upload` v6.0.0 with the dashboard-ready and + target-listing guards still in place. Re-check this only when the action or + Chrome Web Store API announces a new migration window. ### P2 — service quality after the store build is live diff --git a/manifest.json b/manifest.json index 2411488..ea903db 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "description": "__MSG_extDescription__", "version": "3.5.41", "minimum_chrome_version": "124", - "author": "SkillBridge Contributors", + "author": "Heznpc", "homepage_url": "https://github.com/heznpc/skillbridge", "default_locale": "en", "permissions": ["storage", "alarms"], diff --git a/package.json b/package.json index 159c7da..6689ea4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "testPathIgnorePatterns": [ "/node_modules/", "/dist/", + "/.claude/worktrees/", "/tests/e2e/" ], "modulePathIgnorePatterns": [ diff --git a/scripts/build-bundle.js b/scripts/build-bundle.js index 82700c8..9436199 100644 --- a/scripts/build-bundle.js +++ b/scripts/build-bundle.js @@ -63,6 +63,8 @@ async function build() { copyDir(path.join(ROOT, 'assets', 'icons'), path.join(DIST, 'assets', 'icons')); copyDir(path.join(ROOT, '_locales'), path.join(DIST, '_locales')); copyDir(path.join(ROOT, 'src/data'), path.join(DIST, 'src/data')); + fs.copyFileSync(path.join(ROOT, 'LICENSE'), path.join(DIST, 'LICENSE')); + fs.copyFileSync(path.join(ROOT, 'THIRD_PARTY_NOTICES.md'), path.join(DIST, 'THIRD_PARTY_NOTICES.md')); // Copy other web-accessible resources fs.mkdirSync(path.join(DIST, 'src/lib'), { recursive: true }); diff --git a/scripts/build-firefox.js b/scripts/build-firefox.js index 562c99d..b79ec1e 100644 --- a/scripts/build-firefox.js +++ b/scripts/build-firefox.js @@ -85,6 +85,8 @@ for (const dir of ['_locales', 'src']) { copyDir(path.join(ROOT, dir), path.join(DIST_DIR, dir)); } copyDir(path.join(ROOT, 'assets', 'icons'), path.join(DIST_DIR, 'assets', 'icons')); +fs.copyFileSync(path.join(ROOT, 'LICENSE'), path.join(DIST_DIR, 'LICENSE')); +fs.copyFileSync(path.join(ROOT, 'THIRD_PARTY_NOTICES.md'), path.join(DIST_DIR, 'THIRD_PARTY_NOTICES.md')); // Write the Firefox-specific manifest fs.writeFileSync(path.join(DIST_DIR, 'manifest.json'), JSON.stringify(firefoxManifest, null, 2) + '\n'); diff --git a/scripts/check-academy-courses.js b/scripts/check-academy-courses.js index 1c0dfe8..2ce2288 100644 --- a/scripts/check-academy-courses.js +++ b/scripts/check-academy-courses.js @@ -182,7 +182,7 @@ async function main() { ...liveSlugs.map((s) => `- ${knownSlugs.has(s) ? '✅' : '🆕'} \`${s}\``), '', '### Required follow-up (48h terminology SLA)\n', - '1. Add a section for each new course to all 10 premium-language dictionaries in `src/data/`.', + '1. Add a section for each new course to all 12 premium-language dictionaries in `src/data/`.', '2. Map the slug(s) into `FLASHCARD_COURSE_MAP` in `src/lib/constants.js`.', '3. `npm run check:dict-coverage` must pass before the issue closes.', ].join('\n'); diff --git a/scripts/check-dicts.js b/scripts/check-dicts.js index 90ab2cc..143c996 100644 --- a/scripts/check-dicts.js +++ b/scripts/check-dicts.js @@ -7,15 +7,49 @@ * node scripts/check-dicts.js * * In CI (CI=true), writes a report to dict-check-report.txt and exits - * non-zero if any dictionary is stale. + * non-zero if any review-complete dictionary is stale. Dictionaries still + * marked nativeReview=recruiting are explicit review debt, not a fake + * "reviewed" state, so they warn without blocking unrelated dependency PRs. */ const fs = require('fs'); const path = require('path'); -const DATA_DIR = path.join(__dirname, '..', 'src', 'data'); +const DATA_DIR = process.env.SB_DICT_FRESHNESS_DIR || path.join(__dirname, '..', 'src', 'data'); const STALE_THRESHOLD_DAYS = 90; +function writeCiReport(results) { + if (!process.env.CI) return; + + const stale = results.filter((r) => r.status === 'STALE'); + const reviewNeeded = results.filter((r) => r.status === 'REVIEW_NEEDED'); + const report = [ + '### Blocking stale dictionaries\n', + ...(stale.length + ? stale.map( + (r) => + `- **${r.file}** (\`${r.lang}\`): last updated ${r.lastUpdated} (${r.daysSince} days ago), nativeReview=${r.nativeReview} — STALE`, + ) + : ['- None']), + '', + '### Native review recruiting\n', + ...(reviewNeeded.length + ? reviewNeeded.map( + (r) => + `- **${r.file}** (\`${r.lang}\`): last updated ${r.lastUpdated} (${r.daysSince} days ago), nativeReview=${r.nativeReview} — REVIEW_NEEDED`, + ) + : ['- None']), + '', + '### All dictionaries\n', + '| File | Language | Last Updated | Days Ago | Native Review | Status |', + '|------|----------|-------------|----------|---------------|--------|', + ...results + .filter((r) => r.lastUpdated) + .map((r) => `| ${r.file} | ${r.lang} | ${r.lastUpdated} | ${r.daysSince} | ${r.nativeReview} | ${r.status} |`), + ].join('\n'); + fs.writeFileSync('dict-check-report.txt', report); +} + function main() { console.log('Dictionary Freshness Check'); console.log(`Data dir: ${DATA_DIR}`); @@ -36,7 +70,7 @@ function main() { const now = new Date(); const results = []; - let hasStale = false; + let hasBlockingStale = false; for (const file of files) { const filePath = path.join(DATA_DIR, file); @@ -65,43 +99,44 @@ function main() { const daysSince = Math.floor((now - updatedDate) / (1000 * 60 * 60 * 24)); const lang = data._meta.lang || file.replace('.json', ''); + const nativeReview = data._meta.nativeReview || 'unknown'; + const reviewRecruiting = nativeReview === 'recruiting'; if (daysSince > STALE_THRESHOLD_DAYS) { - console.log(` [STALE] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago`); - results.push({ file, lang, status: 'STALE', lastUpdated, daysSince }); - hasStale = true; + const status = reviewRecruiting ? 'REVIEW_NEEDED' : 'STALE'; + const label = reviewRecruiting ? 'REVIEW' : 'STALE'; + console.log( + ` [${label}] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago; nativeReview=${nativeReview}`, + ); + results.push({ file, lang, status, lastUpdated, daysSince, nativeReview }); + if (!reviewRecruiting) hasBlockingStale = true; } else { - console.log(` [OK] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago`); - results.push({ file, lang, status: 'OK', lastUpdated, daysSince }); + console.log( + ` [OK] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago; nativeReview=${nativeReview}`, + ); + results.push({ file, lang, status: 'OK', lastUpdated, daysSince, nativeReview }); } } console.log(''); - if (!hasStale) { - console.log('All dictionaries are fresh.'); + const reviewNeeded = results.filter((r) => r.status === 'REVIEW_NEEDED'); + if (reviewNeeded.length > 0) { + console.log( + `${reviewNeeded.length} dictionary/dictionaries need native review refresh but are marked nativeReview=recruiting.`, + ); + } + + if (!hasBlockingStale) { + writeCiReport(results); + if (reviewNeeded.length === 0) console.log('All dictionaries are fresh.'); + else console.log('No review-complete dictionaries are stale.'); return; } const stale = results.filter((r) => r.status === 'STALE'); - console.log(`${stale.length} dictionary/dictionaries are stale (>${STALE_THRESHOLD_DAYS} days old).`); - - if (process.env.CI) { - const report = [ - '### Stale dictionaries\n', - ...stale.map( - (r) => `- **${r.file}** (\`${r.lang}\`): last updated ${r.lastUpdated} (${r.daysSince} days ago) — STALE`, - ), - '', - '### All dictionaries\n', - '| File | Language | Last Updated | Days Ago | Status |', - '|------|----------|-------------|----------|--------|', - ...results - .filter((r) => r.lastUpdated) - .map((r) => `| ${r.file} | ${r.lang} | ${r.lastUpdated} | ${r.daysSince} | ${r.status} |`), - ].join('\n'); - fs.writeFileSync('dict-check-report.txt', report); - } + console.log(`${stale.length} review-complete dictionary/dictionaries are stale (>${STALE_THRESHOLD_DAYS} days old).`); + writeCiReport(results); process.exit(1); } diff --git a/src/content/content.css b/src/content/content.css index 377a59d..12b3cc8 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -8,8 +8,8 @@ Open Sans (UI) — all Latin-only fonts. CJK and other scripts need proper fallbacks to prevent rendering issues (tofu/boxes). - Strategy: Inject Google Fonts Noto Sans variants per language, - and use :lang() selectors to apply proper font stacks. + Strategy: apply local system/Noto fallback stacks through scoped + si18n-lang-* classes. No remote font fetches are performed. ============================================================ */ /* ============================================================ @@ -27,8 +27,8 @@ looks bolder than Copernicus serif at 700). ============================================================ */ -/* NOTE: @import removed — fonts loaded via tag in content.js - to avoid Chrome extension CSP blocking of @import in content scripts */ +/* NOTE: @import and runtime font injection are intentionally absent. + Extension pages rely on the user's installed fonts and platform fallbacks. */ /* ---- Korean ---- */ diff --git a/src/content/content.js b/src/content/content.js index 3ec57fb..87df80d 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -758,36 +758,6 @@ } } - // Language → Google Fonts family (load only what's needed) - const _LANG_FONT_MAP = { - ko: 'Noto+Sans+KR', - ja: 'Noto+Sans+JP', - 'zh-CN': 'Noto+Sans+SC', - 'zh-TW': 'Noto+Sans+TC', - ar: 'Noto+Sans+Arabic', - hi: 'Noto+Sans+Devanagari', - bn: 'Noto+Sans+Bengali', - th: 'Noto+Sans+Thai', - he: 'Noto+Sans+Hebrew', - }; - - function injectGoogleFonts(lang) { - if (document.getElementById('sb-google-fonts')) return; - const family = _LANG_FONT_MAP[lang]; - if (!family) return; // Latin-script languages use system fonts - - const link = document.createElement('link'); - link.id = 'sb-google-fonts'; - link.rel = 'stylesheet'; - link.href = `https://fonts.googleapis.com/css2?family=${family}:wght@400;500;700&display=swap`; - link.onerror = () => { - // Graceful fallback — system fonts will be used via CSS font-family stack - console.debug('[SkillBridge] Google Fonts unavailable, using system fonts'); - link.remove(); - }; - document.head.appendChild(link); - } - function updateLangClass(lang) { // html lang (screen readers) + dir (RTL) are page-level semantics — set on // the document regardless of scope. @@ -804,7 +774,6 @@ for (const cls of [...el.classList].filter((c) => c.startsWith('si18n-lang-'))) el.classList.remove(cls); if (lang && lang !== 'en') el.classList.add(`si18n-lang-${lang}`); } - if (lang && lang !== 'en') injectGoogleFonts(lang); } function safeReplaceText(el, newText) { diff --git a/src/content/gt-queue.js b/src/content/gt-queue.js index 8f0f858..ef85575 100644 --- a/src/content/gt-queue.js +++ b/src/content/gt-queue.js @@ -379,11 +379,12 @@ if (gtGeneration !== myGeneration) return; const uncached = []; + const useGeminiBlocks = sb.hostCaps?.bridge !== false; for (let i = 0; i < batch.length; i++) { if (cacheResults[i]) { const item = batch[i]; if (item.el && item.el.parentNode) { - if (item.needsGemini) { + if (item.needsGemini && useGeminiBlocks) { uncached.push(item); } else { const translated = window._protectedTerms.restoreProtectedTerms(cacheResults[i]); @@ -396,8 +397,8 @@ } } - const gtItems = uncached.filter((item) => !item.needsGemini); - const geminiItems = uncached.filter((item) => item.needsGemini); + const gtItems = uncached.filter((item) => !item.needsGemini || !useGeminiBlocks); + const geminiItems = useGeminiBlocks ? uncached.filter((item) => item.needsGemini) : []; for (const item of geminiItems) { if (item.el && item.el.parentNode) { diff --git a/src/lib/constants.js b/src/lib/constants.js index 6eeaa3e..0167788 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -1631,6 +1631,11 @@ const FLASHCARD_COURSE_MAP = { 'claude-with-google-vertex': ['cloudDeployment'], // Claude Developer Platform 'claude-platform-101': ['claudePlatform'], + // Certification / FAQ pages are public catalog entries. They do not have a + // dedicated terminology deck yet, so route them to the closest Claude platform + // deck instead of failing the live catalog drift gate. + 'certification-faq': ['claudePlatform'], + 'claude-certified-architect-foundations-certification': ['claudePlatform'], // AI Fluency courses 'ai-fluency': ['aiFluency'], 'ai-fluency-framework': ['aiFluency'], diff --git a/src/lib/protected-terms.js b/src/lib/protected-terms.js index f9497d4..ac4c474 100644 --- a/src/lib/protected-terms.js +++ b/src/lib/protected-terms.js @@ -15,6 +15,13 @@ let _protectedKeepEnglish = ''; let _selfDupRe = null; + const CORE_PROTECTED_TERMS = Object.freeze({ + Claude: [], + Anthropic: [], + API: [], + SDK: [], + }); + /** * Build the map of wrong->correct term replacements for the given language. * No-ops if the map is already built for the same language. @@ -27,7 +34,7 @@ _protectedTermsLang = targetLang; const map = {}; - const protectedEntries = translator.getProtectedTerms?.() || {}; + const protectedEntries = { ...CORE_PROTECTED_TERMS, ...(translator.getProtectedTerms?.() || {}) }; for (const [correct, wrongForms] of Object.entries(protectedEntries)) { if (!Array.isArray(wrongForms)) continue; for (const wrong of wrongForms) { diff --git a/src/popup/popup.html b/src/popup/popup.html index 5569d4b..d1a7567 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -208,8 +208,8 @@
- Navigate to anthropic.skilljar.com to start - learning. + Open an Anthropic Skilljar course or a + Claude tutorial to start learning.
diff --git a/src/popup/popup.js b/src/popup/popup.js index bf6db63..2da1b38 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -19,18 +19,31 @@ function isSkilljarHost(url) { } } +function isClaudeTutorialUrl(url) { + try { + const parsed = new URL(url); + return parsed.hostname === 'claude.com' && parsed.pathname.startsWith('/resources/tutorials/'); + } catch { + return false; + } +} + +function isSupportedPage(url) { + return isSkilljarHost(url) || isClaudeTutorialUrl(url); +} + document.addEventListener('DOMContentLoaded', async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - const isSkilljar = isSkilljarHost(tab?.url); + const isSupported = isSupportedPage(tab?.url); - document.getElementById('main-content').style.display = isSkilljar ? 'block' : 'none'; - document.getElementById('not-skilljar').style.display = isSkilljar ? 'none' : 'block'; + document.getElementById('main-content').style.display = isSupported ? 'block' : 'none'; + document.getElementById('not-skilljar').style.display = isSupported ? 'none' : 'block'; // Footer from model constants document.getElementById('footer').innerHTML = `Google Translate + ${SKILLBRIDGE_MODEL_LABELS.GEMINI}