Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/",
"/.claude/worktrees/",
"/tests/e2e/"
],
"modulePathIgnorePatterns": [
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-firefox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-academy-courses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
91 changes: 63 additions & 28 deletions scripts/check-dicts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 4 additions & 4 deletions src/content/content.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.
============================================================ */

/* ============================================================
Expand All @@ -27,8 +27,8 @@
looks bolder than Copernicus serif at 700).
============================================================ */

/* NOTE: @import removed — fonts loaded via <link> tag in content.js
to avoid Chrome extension CSP blocking of @import in content scripts */
/* NOTE: @import and runtime <link> font injection are intentionally absent.
Extension pages rely on the user's installed fonts and platform fallbacks. */

/* ---- Korean ---- */

Expand Down
31 changes: 0 additions & 31 deletions src/content/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
7 changes: 4 additions & 3 deletions src/content/gt-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
9 changes: 8 additions & 1 deletion src/lib/protected-terms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ <h1>SkillBridge</h1>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<p>
Navigate to <a href="https://anthropic.skilljar.com" target="_blank">anthropic.skilljar.com</a> to start
learning.
Open an Anthropic Skilljar course or a
<a href="https://claude.com/resources/tutorials" target="_blank">Claude tutorial</a> to start learning.
</p>
</div>

Expand Down
21 changes: 17 additions & 4 deletions src/popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}<br>AI Tutor: ${SKILLBRIDGE_MODEL_LABELS.CLAUDE}`;

if (!isSkilljar) return;
if (!isSupported) return;

const stored = await chrome.storage.local.get(['targetLanguage', 'autoTranslate']);
let lang = stored.targetLanguage || 'en';
Expand Down
Binary file modified store-assets/01-translate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified store-assets/02-language-select.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified store-assets/03-sidebar-tutor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified store-assets/04-flashcards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified store-assets/05-exam-safe.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading