From 75a5449175d377bce73a74ee5536987fc98d3e7e Mon Sep 17 00:00:00 2001
From: YutaSakuma-hash <81286@g.ecc.u-tokyo.ac.jp>
Date: Thu, 10 Sep 2026 11:39:00 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E3=81=8A=E6=B0=97=E3=81=AB?=
=?UTF-8?q?=E5=85=A5=E3=82=8A=E6=A9=9F=E8=83=BD=E3=81=A8=E3=82=B5=E3=82=A4?=
=?UTF-8?q?=E3=83=89=E3=83=91=E3=83=8D=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 4 +
package-lock.json | 38 ++++++++
packages/browser/manifest.json | 11 +++
packages/browser/package.json | 7 +-
packages/browser/sidepanel.css | 134 +++++++++++++++++++++++++++++
packages/browser/sidepanel.html | 27 ++++++
packages/browser/src/background.ts | 3 +
packages/browser/src/content.ts | 83 +++++++++++++++++-
packages/browser/src/favorites.ts | 56 ++++++++++++
packages/browser/src/sidepanel.ts | 119 +++++++++++++++++++++++++
packages/browser/tsconfig.json | 13 +++
11 files changed, 491 insertions(+), 4 deletions(-)
create mode 100644 packages/browser/sidepanel.css
create mode 100644 packages/browser/sidepanel.html
create mode 100644 packages/browser/src/background.ts
create mode 100644 packages/browser/src/favorites.ts
create mode 100644 packages/browser/src/sidepanel.ts
create mode 100644 packages/browser/tsconfig.json
diff --git a/README.md b/README.md
index 9acecd0..88b4c37 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,10 @@ npm run build -w @techword/browser
`chrome://extensions` → デベロッパーモード ON → 「パッケージ化されていない拡張機能を読み込む」
で `packages/browser/dist` を選択。任意のページで用語にホバーします。
+ホバー内の `☆` を押すと用語をお気に入りに追加でき、もう一度押すと解除できます。
+Chrome のツールバーにある TechTerm のアイコンを押すとサイドパネルが開き、
+お気に入りの確認、個別削除、全削除ができます。お気に入りは Chrome の同期ストレージに保存されます。
+
## 辞書を増やす
`packages/core/src/data/terms.json` に追記します。
diff --git a/package-lock.json b/package-lock.json
index 9e02975..25332c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -452,6 +452,41 @@
"resolved": "packages/core",
"link": true
},
+ "node_modules/@types/chrome": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.9.tgz",
+ "integrity": "sha512-6rXrDPkkWv4D2Q23HqT+iED4voODU5CpOSc2VFgKyecSFItuEsIBvz9CY6jUQ3dd+Q7zo1bnCE9pKRMtkYut5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filesystem": "*",
+ "@types/har-format": "*"
+ }
+ },
+ "node_modules/@types/filesystem": {
+ "version": "0.0.36",
+ "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
+ "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filewriter": "*"
+ }
+ },
+ "node_modules/@types/filewriter": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
+ "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/har-format": {
+ "version": "1.2.16",
+ "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+ "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -1116,6 +1151,9 @@
"version": "0.1.1",
"dependencies": {
"@techword/core": "*"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.2.9"
}
},
"packages/core": {
diff --git a/packages/browser/manifest.json b/packages/browser/manifest.json
index 1e0a37d..dddea6b 100644
--- a/packages/browser/manifest.json
+++ b/packages/browser/manifest.json
@@ -2,7 +2,18 @@
"manifest_version": 3,
"name": "TechTerm",
"version": "0.1.1",
+ "minimum_chrome_version": "114",
"description": "プログラミング用語にホバーすると、意味を簡潔に表示します。",
+ "permissions": ["storage", "sidePanel"],
+ "action": {
+ "default_title": "TechTermのお気に入りを開く"
+ },
+ "background": {
+ "service_worker": "background.js"
+ },
+ "side_panel": {
+ "default_path": "sidepanel.html"
+ },
"content_scripts": [
{
"matches": [
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 865c587..69b8c64 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -4,10 +4,13 @@
"private": true,
"typescript": "^6.0.3",
"scripts": {
- "build": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 && cp manifest.json dist/manifest.json",
- "watch": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 --watch"
+ "build": "tsc -p tsconfig.json --noEmit && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 && cp manifest.json sidepanel.html sidepanel.css dist/",
+ "watch": "mkdir -p dist && cp manifest.json sidepanel.html sidepanel.css dist/ && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 --watch"
},
"dependencies": {
"@techword/core": "*"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.2.9"
}
}
diff --git a/packages/browser/sidepanel.css b/packages/browser/sidepanel.css
new file mode 100644
index 0000000..e78f374
--- /dev/null
+++ b/packages/browser/sidepanel.css
@@ -0,0 +1,134 @@
+:root {
+ color-scheme: light dark;
+ font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif;
+ background: Canvas;
+ color: CanvasText;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ padding: 18px 16px 28px;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 20px;
+ line-height: 1.3;
+}
+
+.count {
+ margin-top: 3px;
+ color: GrayText;
+ font-size: 12px;
+}
+
+button {
+ border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
+ border-radius: 7px;
+ background: ButtonFace;
+ color: ButtonText;
+ cursor: pointer;
+ font: inherit;
+}
+
+button:hover:not(:disabled) {
+ background: color-mix(in srgb, ButtonFace 80%, CanvasText 20%);
+}
+
+button:focus-visible,
+a:focus-visible {
+ outline: 2px solid Highlight;
+ outline-offset: 2px;
+}
+
+button:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+.clear-favorites {
+ flex: none;
+ padding: 7px 10px;
+ font-size: 12px;
+}
+
+.favorites {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.favorite-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 12px;
+ border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
+ border-radius: 9px;
+}
+
+.favorite-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.favorite-body h2 {
+ font-size: 15px;
+ line-height: 1.4;
+}
+
+.favorite-body p {
+ margin-top: 4px;
+ color: color-mix(in srgb, CanvasText 76%, transparent);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.favorite-body a {
+ display: inline-block;
+ margin-top: 6px;
+ color: LinkText;
+ font-size: 12px;
+}
+
+.remove-favorite {
+ flex: none;
+ padding: 6px 8px;
+ color: #c62828;
+ font-size: 12px;
+}
+
+.empty {
+ padding: 30px 8px;
+ color: GrayText;
+ text-align: center;
+ font-size: 13px;
+}
+
+.error {
+ margin-bottom: 12px;
+ color: #c62828;
+ font-size: 13px;
+}
diff --git a/packages/browser/sidepanel.html b/packages/browser/sidepanel.html
new file mode 100644
index 0000000..a83d89c
--- /dev/null
+++ b/packages/browser/sidepanel.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ TechTerm お気に入り
+
+
+
+
+
+
+
+ お気に入りはまだありません。
+
+
+
+
+
diff --git a/packages/browser/src/background.ts b/packages/browser/src/background.ts
new file mode 100644
index 0000000..0d9113c
--- /dev/null
+++ b/packages/browser/src/background.ts
@@ -0,0 +1,3 @@
+chrome.sidePanel
+ .setPanelBehavior({ openPanelOnActionClick: true })
+ .catch((error: unknown) => console.error('TechTerm: サイドパネルを設定できませんでした。', error));
diff --git a/packages/browser/src/content.ts b/packages/browser/src/content.ts
index e08afb2..9cc4f7b 100644
--- a/packages/browser/src/content.ts
+++ b/packages/browser/src/content.ts
@@ -1,4 +1,5 @@
import { Dictionary, builtinTerms, type Match } from '@techword/core';
+import { getFavoriteIds, toggleFavorite, watchFavoriteIds } from './favorites';
const dictionary = new Dictionary(builtinTerms);
@@ -8,14 +9,39 @@ const HOVER_DELAY_MS = 250;
const tooltip = createTooltip();
let hoverTimer: number | undefined;
let currentId: string | undefined;
+let currentFavoriteButton: HTMLButtonElement | undefined;
+let favoriteIds = new Set();
+let pointerInsideTooltip = false;
+
+void getFavoriteIds()
+ .then((ids) => {
+ favoriteIds = new Set(ids);
+ refreshCurrentFavoriteButton();
+ })
+ .catch((error: unknown) => console.error('TechTerm: お気に入りを読み込めませんでした。', error));
+
+watchFavoriteIds((ids) => {
+ favoriteIds = new Set(ids);
+ refreshCurrentFavoriteButton();
+});
document.addEventListener('mousemove', (event) => {
window.clearTimeout(hoverTimer);
+ if (event.composedPath().includes(tooltip.host) || pointerInsideTooltip) return;
hoverTimer = window.setTimeout(() => handleHover(event), HOVER_DELAY_MS);
});
document.addEventListener('scroll', hide, { passive: true, capture: true });
window.addEventListener('blur', hide);
+tooltip.host.addEventListener('pointerenter', () => {
+ pointerInsideTooltip = true;
+ window.clearTimeout(hoverTimer);
+});
+tooltip.host.addEventListener('pointerleave', () => {
+ pointerInsideTooltip = false;
+ hide();
+});
+
function handleHover(event: MouseEvent): void {
const caret = caretFromPoint(event.clientX, event.clientY);
if (!caret || caret.node.nodeType !== Node.TEXT_NODE) return hide();
@@ -33,6 +59,8 @@ function handleHover(event: MouseEvent): void {
function hide(): void {
currentId = undefined;
+ currentFavoriteButton = undefined;
+ pointerInsideTooltip = false;
tooltip.host.style.display = 'none';
}
@@ -57,10 +85,37 @@ function render({ entry }: Match): void {
const { panel } = tooltip;
panel.replaceChildren();
+ const header = document.createElement('div');
+ header.className = 'tw-header';
+
const head = document.createElement('div');
head.className = 'tw-head';
head.textContent = entry.term;
- panel.append(head);
+
+ const favoriteButton = document.createElement('button');
+ favoriteButton.type = 'button';
+ favoriteButton.className = 'tw-favorite';
+ updateFavoriteButton(favoriteButton, entry.id);
+ favoriteButton.addEventListener('click', async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ favoriteButton.disabled = true;
+
+ try {
+ const isFavorite = await toggleFavorite(entry.id);
+ if (isFavorite) favoriteIds.add(entry.id);
+ else favoriteIds.delete(entry.id);
+ updateFavoriteButton(favoriteButton, entry.id);
+ } catch (error: unknown) {
+ console.error('TechTerm: お気に入りを更新できませんでした。', error);
+ } finally {
+ favoriteButton.disabled = false;
+ }
+ });
+ currentFavoriteButton = favoriteButton;
+
+ header.append(head, favoriteButton);
+ panel.append(header);
const short = document.createElement('div');
short.className = 'tw-short';
@@ -104,7 +159,16 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
box-shadow: 0 6px 20px rgba(0,0,0,.35);
font: 13px/1.6 -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif;
}
- .tw-head { font-weight: 700; margin-bottom: 2px; }
+ .tw-header { display: flex; align-items: center; gap: 10px; margin-bottom: 2px; }
+ .tw-head { flex: 1; min-width: 0; font-weight: 700; }
+ .tw-favorite {
+ display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0;
+ border: 1px solid #596276; border-radius: 7px; background: #2b3241; color: #ffd166;
+ cursor: pointer; font: 20px/1 sans-serif;
+ }
+ .tw-favorite:hover { background: #394256; }
+ .tw-favorite:focus-visible { outline: 2px solid #7fb2ff; outline-offset: 2px; }
+ .tw-favorite:disabled { cursor: wait; opacity: .65; }
.tw-short { color: #f2f2f2; }
.tw-detail { margin-top: 6px; color: #b9c0cf; }
.tw-example {
@@ -122,6 +186,21 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
return { host, panel };
}
+function updateFavoriteButton(button: HTMLButtonElement, id: string): void {
+ const isFavorite = favoriteIds.has(id);
+ const label = isFavorite ? 'お気に入りから削除' : 'お気に入りに追加';
+ button.textContent = isFavorite ? '★' : '☆';
+ button.setAttribute('aria-pressed', String(isFavorite));
+ button.setAttribute('aria-label', label);
+ button.title = label;
+}
+
+function refreshCurrentFavoriteButton(): void {
+ if (currentId && currentFavoriteButton) {
+ updateFavoriteButton(currentFavoriteButton, currentId);
+ }
+}
+
/** Chrome (caretRangeFromPoint) と Firefox (caretPositionFromPoint) の差を吸収する。 */
function caretFromPoint(x: number, y: number): { node: Node; offset: number } | undefined {
const doc = document as Document & {
diff --git a/packages/browser/src/favorites.ts b/packages/browser/src/favorites.ts
new file mode 100644
index 0000000..891451b
--- /dev/null
+++ b/packages/browser/src/favorites.ts
@@ -0,0 +1,56 @@
+export const FAVORITES_STORAGE_KEY = 'favoriteTermIds';
+
+type FavoriteIdsListener = (ids: string[]) => void;
+
+function sanitizeFavoriteIds(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return [
+ ...new Set(value.filter((id): id is string => typeof id === 'string' && id.length > 0)),
+ ];
+}
+
+export async function getFavoriteIds(): Promise {
+ const result = await chrome.storage.sync.get(FAVORITES_STORAGE_KEY);
+ return sanitizeFavoriteIds(result[FAVORITES_STORAGE_KEY]);
+}
+
+async function setFavoriteIds(ids: string[]): Promise {
+ await chrome.storage.sync.set({
+ [FAVORITES_STORAGE_KEY]: sanitizeFavoriteIds(ids),
+ });
+}
+
+export async function toggleFavorite(id: string): Promise {
+ const ids = await getFavoriteIds();
+ const nextIsFavorite = !ids.includes(id);
+ const nextIds = nextIsFavorite
+ ? [...ids, id]
+ : ids.filter((currentId) => currentId !== id);
+
+ await setFavoriteIds(nextIds);
+ return nextIsFavorite;
+}
+
+export async function removeFavorite(id: string): Promise {
+ const ids = await getFavoriteIds();
+ await setFavoriteIds(ids.filter((currentId) => currentId !== id));
+}
+
+export async function clearFavorites(): Promise {
+ await setFavoriteIds([]);
+}
+
+export function watchFavoriteIds(listener: FavoriteIdsListener): () => void {
+ const handleChange = (
+ changes: Record,
+ areaName: string,
+ ): void => {
+ if (areaName !== 'sync') return;
+ const change = changes[FAVORITES_STORAGE_KEY];
+ if (!change) return;
+ listener(sanitizeFavoriteIds(change.newValue));
+ };
+
+ chrome.storage.onChanged.addListener(handleChange);
+ return () => chrome.storage.onChanged.removeListener(handleChange);
+}
diff --git a/packages/browser/src/sidepanel.ts b/packages/browser/src/sidepanel.ts
new file mode 100644
index 0000000..36cd84f
--- /dev/null
+++ b/packages/browser/src/sidepanel.ts
@@ -0,0 +1,119 @@
+import { builtinTerms, type TermEntry } from '@techword/core';
+import {
+ clearFavorites,
+ getFavoriteIds,
+ removeFavorite,
+ watchFavoriteIds,
+} from './favorites';
+
+const favoriteList = requireElement('favorites');
+const emptyMessage = requireElement('empty');
+const count = requireElement('count');
+const clearButton = requireElement('clear-favorites');
+const errorMessage = requireElement('error');
+const termsById = new Map(builtinTerms.map((entry) => [entry.id, entry]));
+
+let currentFavoriteIds: string[] = [];
+
+clearButton.addEventListener('click', async () => {
+ if (currentFavoriteIds.length === 0) return;
+ if (!window.confirm('お気に入りをすべて削除しますか?')) return;
+
+ clearButton.disabled = true;
+ hideError();
+ try {
+ await clearFavorites();
+ renderFavorites([]);
+ } catch (error: unknown) {
+ showError('お気に入りを削除できませんでした。', error);
+ clearButton.disabled = false;
+ }
+});
+
+watchFavoriteIds(renderFavorites);
+void loadFavorites();
+
+async function loadFavorites(): Promise {
+ hideError();
+ try {
+ renderFavorites(await getFavoriteIds());
+ } catch (error: unknown) {
+ renderFavorites([]);
+ showError('お気に入りを読み込めませんでした。', error);
+ }
+}
+
+function renderFavorites(ids: string[]): void {
+ currentFavoriteIds = ids;
+ const entries = ids
+ .map((id) => termsById.get(id))
+ .filter((entry): entry is TermEntry => entry !== undefined);
+
+ favoriteList.replaceChildren(...entries.map(createFavoriteItem));
+ favoriteList.hidden = entries.length === 0;
+ emptyMessage.hidden = entries.length !== 0;
+ count.textContent = `${entries.length}件`;
+ clearButton.disabled = ids.length === 0;
+}
+
+function createFavoriteItem(entry: TermEntry): HTMLLIElement {
+ const item = document.createElement('li');
+ item.className = 'favorite-item';
+
+ const body = document.createElement('div');
+ body.className = 'favorite-body';
+
+ const term = document.createElement('h2');
+ term.textContent = entry.term;
+
+ const description = document.createElement('p');
+ description.textContent = entry.short;
+
+ body.append(term, description);
+
+ if (entry.link) {
+ const link = document.createElement('a');
+ link.href = entry.link;
+ link.target = '_blank';
+ link.rel = 'noreferrer noopener';
+ link.textContent = '詳しく';
+ body.append(link);
+ }
+
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.className = 'remove-favorite';
+ removeButton.textContent = '削除';
+ removeButton.setAttribute('aria-label', `${entry.term}をお気に入りから削除`);
+ removeButton.addEventListener('click', async () => {
+ removeButton.disabled = true;
+ hideError();
+ try {
+ await removeFavorite(entry.id);
+ renderFavorites(currentFavoriteIds.filter((id) => id !== entry.id));
+ } catch (error: unknown) {
+ showError(`${entry.term}を削除できませんでした。`, error);
+ removeButton.disabled = false;
+ }
+ });
+
+ item.append(body, removeButton);
+ return item;
+}
+
+function requireElement(id: string): T {
+ const element = document.getElementById(id);
+ if (!element) throw new Error(`Missing element: #${id}`);
+ return element as T;
+}
+
+function hideError(): void {
+ errorMessage.hidden = true;
+ errorMessage.textContent = '';
+}
+
+function showError(message: string, error: unknown): void {
+ console.error(`TechTerm: ${message}`, error);
+ errorMessage.textContent = message;
+ errorMessage.hidden = false;
+}
diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json
new file mode 100644
index 0000000..e7b5bb3
--- /dev/null
+++ b/packages/browser/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM"],
+ "types": ["chrome"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"]
+}
From 133aab1bf5a0afece537a3b6e227f75f49a2904c Mon Sep 17 00:00:00 2001
From: YutaSakuma-hash <81286@g.ecc.u-tokyo.ac.jp>
Date: Fri, 18 Sep 2026 16:11:54 +0900
Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E7=94=A8=E8=AA=9E=E3=82=AB?=
=?UTF-8?q?=E3=83=86=E3=82=B4=E3=83=AA=E8=A8=AD=E5=AE=9A=E3=81=A8=E5=B7=AE?=
=?UTF-8?q?=E5=88=86=E3=83=8F=E3=82=A4=E3=83=A9=E3=82=A4=E3=83=88=E3=82=92?=
=?UTF-8?q?=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 17 +
package-lock.json | 579 +++++++++++++-
package.json | 2 +-
packages/browser/manifest.json | 11 +
packages/browser/package.json | 9 +-
packages/browser/sidepanel.css | 147 ++++
packages/browser/sidepanel.html | 35 +
packages/browser/src/background.ts | 3 +
packages/browser/src/category-settings.ts | 54 ++
packages/browser/src/content.ts | 131 +++-
packages/browser/src/favorites.ts | 56 ++
packages/browser/src/highlighter.ts | 110 +++
packages/browser/src/sidepanel.ts | 178 +++++
packages/browser/tests/browser.test.cjs | 171 ++++
packages/browser/tsconfig.json | 13 +
packages/core/src/categories.ts | 12 +
packages/core/src/data/terms.json | 905 ++++++++++++++++++++++
packages/core/src/dictionary.test.ts | 36 +-
packages/core/src/dictionary.ts | 56 +-
packages/core/src/index.ts | 3 +
packages/core/src/normalize.ts | 3 +-
packages/core/src/types.ts | 4 +
22 files changed, 2488 insertions(+), 47 deletions(-)
create mode 100644 packages/browser/sidepanel.css
create mode 100644 packages/browser/sidepanel.html
create mode 100644 packages/browser/src/background.ts
create mode 100644 packages/browser/src/category-settings.ts
create mode 100644 packages/browser/src/favorites.ts
create mode 100644 packages/browser/src/highlighter.ts
create mode 100644 packages/browser/src/sidepanel.ts
create mode 100644 packages/browser/tests/browser.test.cjs
create mode 100644 packages/browser/tsconfig.json
create mode 100644 packages/core/src/categories.ts
diff --git a/README.md b/README.md
index 9acecd0..9d08c02 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,10 @@ npm run build -w @techword/browser
`chrome://extensions` → デベロッパーモード ON → 「パッケージ化されていない拡張機能を読み込む」
で `packages/browser/dist` を選択。任意のページで用語にホバーします。
+ホバー内の `☆` を押すと用語をお気に入りに追加でき、もう一度押すと解除できます。
+Chrome のツールバーにある TechTerm のアイコンを押すとサイドパネルが開き、
+お気に入りの確認、個別削除、全削除ができます。お気に入りは Chrome の同期ストレージに保存されます。
+
## 辞書を増やす
`packages/core/src/data/terms.json` に追記します。
@@ -73,3 +77,16 @@ VS Code 側は設定 `techword.userTerms` からも同じ形式で追加・上
(最大 4 トークン、長い一致から順に)。
- **漢字・ひらがなは切り出さない**。形態素解析なしでは境界が決まらないため、
日本語の別名はカタカナか英字で `match` に書く運用です。
+
+### 表示する用語の設定
+
+拡張機能アイコンからサイドパネルを開き、「表示する用語」でカテゴリを選択できます。
+初期状態では全カテゴリが有効です。チェックを外すと、そのカテゴリのハイライトと
+ホバー説明が開いているページでも無効になります。お気に入りは削除されません。
+設定はChromeの同期ストレージに保存されます。
+
+通常のページ更新では、追加・変更されたテキストだけを再検索します。
+削除したテキストのハイライトは除去し、初回表示とカテゴリ設定の変更時だけページ全体を検索します。
+
+`npm run build` のあとに `npm test` を実行すると、辞書に加えて、差分更新・カテゴリ設定・
+サイドパネルの保存失敗時の動作も検証します。
diff --git a/package-lock.json b/package-lock.json
index 9e02975..b559b6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "TechTerm",
- "version": "0.1.1",
+ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "TechTerm",
- "version": "0.1.1",
+ "version": "0.2.1",
"workspaces": [
"packages/core",
"packages/vscode",
@@ -19,6 +19,135 @@
"typescript": "^5.9.3"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
@@ -452,6 +581,41 @@
"resolved": "packages/core",
"link": true
},
+ "node_modules/@types/chrome": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.9.tgz",
+ "integrity": "sha512-6rXrDPkkWv4D2Q23HqT+iED4voODU5CpOSc2VFgKyecSFItuEsIBvz9CY6jUQ3dd+Q7zo1bnCE9pKRMtkYut5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filesystem": "*",
+ "@types/har-format": "*"
+ }
+ },
+ "node_modules/@types/filesystem": {
+ "version": "0.0.36",
+ "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
+ "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filewriter": "*"
+ }
+ },
+ "node_modules/@types/filewriter": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
+ "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/har-format": {
+ "version": "1.2.16",
+ "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+ "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -482,6 +646,16 @@
"node": ">=6.5"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/archiver": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz",
@@ -740,6 +914,72 @@
"node": ">=18"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
@@ -818,6 +1058,60 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -846,6 +1140,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
@@ -866,6 +1167,46 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -912,6 +1253,13 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -928,6 +1276,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -938,6 +1293,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.27",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
+ "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -955,6 +1330,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
@@ -988,6 +1373,13 @@
"url": "https://github.com/sponsors/yqnn"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1009,6 +1401,26 @@
],
"license": "MIT"
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
@@ -1031,6 +1443,13 @@
"safe-buffer": "~5.2.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tar-stream": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",
@@ -1068,6 +1487,52 @@
"b4a": "^1.6.4"
}
},
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1096,6 +1561,106 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/zip-stream": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz",
@@ -1113,18 +1678,22 @@
},
"packages/browser": {
"name": "@techword/browser",
- "version": "0.1.1",
+ "version": "0.2.1",
"dependencies": {
"@techword/core": "*"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.2.9",
+ "jsdom": "^26.1.0"
}
},
"packages/core": {
"name": "@techword/core",
- "version": "0.1.1"
+ "version": "0.2.1"
},
"packages/vscode": {
"name": "techword-vscode",
- "version": "0.1.1",
+ "version": "0.2.1",
"dependencies": {
"@techword/core": "*"
},
diff --git a/package.json b/package.json
index 8d95e00..5625ccd 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"scripts": {
"build": "npm run build -w @techword/core && npm run build -w techword-vscode && npm run build -w @techword/browser",
"watch:core": "npm run watch -w @techword/core",
- "test": "npm test -w @techword/core"
+ "test": "npm test -w @techword/core && npm test -w @techword/browser"
},
"devDependencies": {
"@types/node": "^22.20.1",
diff --git a/packages/browser/manifest.json b/packages/browser/manifest.json
index d450d81..a003eb5 100644
--- a/packages/browser/manifest.json
+++ b/packages/browser/manifest.json
@@ -2,7 +2,18 @@
"manifest_version": 3,
"name": "TechTerm",
"version": "0.2.1",
+ "minimum_chrome_version": "114",
"description": "プログラミング用語にホバーすると、意味を簡潔に表示します。",
+ "permissions": ["storage", "sidePanel"],
+ "action": {
+ "default_title": "TechTermのお気に入りを開く"
+ },
+ "background": {
+ "service_worker": "background.js"
+ },
+ "side_panel": {
+ "default_path": "sidepanel.html"
+ },
"content_scripts": [
{
"matches": [
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 562184e..10c1347 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -4,10 +4,15 @@
"private": true,
"typescript": "^6.0.3",
"scripts": {
- "build": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 && cp manifest.json dist/manifest.json",
- "watch": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 --watch"
+ "build": "tsc -p tsconfig.json --noEmit && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 && cp manifest.json sidepanel.html sidepanel.css dist/",
+ "watch": "mkdir -p dist && cp manifest.json sidepanel.html sidepanel.css dist/ && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 --watch",
+ "test": "node --test tests/*.test.cjs"
},
"dependencies": {
"@techword/core": "*"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.2.9",
+ "jsdom": "^26.1.0"
}
}
diff --git a/packages/browser/sidepanel.css b/packages/browser/sidepanel.css
new file mode 100644
index 0000000..bb59fa5
--- /dev/null
+++ b/packages/browser/sidepanel.css
@@ -0,0 +1,147 @@
+:root {
+ color-scheme: light dark;
+ font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif;
+ background: Canvas;
+ color: CanvasText;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ padding: 18px 16px 28px;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 20px;
+ line-height: 1.3;
+}
+
+.count {
+ margin-top: 3px;
+ color: GrayText;
+ font-size: 12px;
+}
+
+button {
+ border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
+ border-radius: 7px;
+ background: ButtonFace;
+ color: ButtonText;
+ cursor: pointer;
+ font: inherit;
+}
+
+button:hover:not(:disabled) {
+ background: color-mix(in srgb, ButtonFace 80%, CanvasText 20%);
+}
+
+button:focus-visible,
+a:focus-visible {
+ outline: 2px solid Highlight;
+ outline-offset: 2px;
+}
+
+button:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+.clear-favorites {
+ flex: none;
+ padding: 7px 10px;
+ font-size: 12px;
+}
+
+.favorites {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.favorite-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 12px;
+ border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
+ border-radius: 9px;
+}
+
+.favorite-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.favorite-body h2 {
+ font-size: 15px;
+ line-height: 1.4;
+}
+
+.favorite-body p {
+ margin-top: 4px;
+ color: color-mix(in srgb, CanvasText 76%, transparent);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.favorite-body a {
+ display: inline-block;
+ margin-top: 6px;
+ color: LinkText;
+ font-size: 12px;
+}
+
+.remove-favorite {
+ flex: none;
+ padding: 6px 8px;
+ color: #c62828;
+ font-size: 12px;
+}
+
+.empty {
+ padding: 30px 8px;
+ color: GrayText;
+ text-align: center;
+ font-size: 13px;
+}
+
+.error {
+ margin-bottom: 12px;
+ color: #c62828;
+ font-size: 13px;
+}
+
+[hidden] { display: none !important; }
+.term-settings { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid color-mix(in srgb, CanvasText 18%, transparent); }
+.term-settings summary { padding: 6px 0; cursor: pointer; }
+.term-settings summary:focus-visible { outline: 2px solid Highlight; outline-offset: 4px; border-radius: 4px; }
+.term-settings h2 { display: inline; font-size: 18px; }
+.settings-description, .settings-note, .settings-status { margin-top: 8px; font-size: 12px; line-height: 1.6; color: GrayText; }
+.term-categories { display: grid; gap: 4px; margin-top: 12px; }
+.category-row { display: flex; align-items: center; gap: 10px; padding: 9px 6px; font-size: 13px; cursor: pointer; }
+.category-row:hover { background: color-mix(in srgb, CanvasText 6%, transparent); border-radius: 6px; }
+.category-row input { width: 17px; height: 17px; margin: 0; flex: none; accent-color: #4272d7; }
+.category-row input:focus-visible { outline: 2px solid Highlight; outline-offset: 3px; }
+.category-count { margin-left: auto; color: GrayText; white-space: nowrap; }
diff --git a/packages/browser/sidepanel.html b/packages/browser/sidepanel.html
new file mode 100644
index 0000000..0473d00
--- /dev/null
+++ b/packages/browser/sidepanel.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ TechTerm
+
+
+
+
+
+ 表示する用語
+ チェックしたカテゴリの用語を強調表示し、カーソルを合わせると説明を表示します。
+
+ 設定を読み込み中…
+
+ カテゴリを無効にしても、お気に入りは残ります。
+
+
+
+
+ お気に入りはまだありません。
+
+
+
+
+
diff --git a/packages/browser/src/background.ts b/packages/browser/src/background.ts
new file mode 100644
index 0000000..0d9113c
--- /dev/null
+++ b/packages/browser/src/background.ts
@@ -0,0 +1,3 @@
+chrome.sidePanel
+ .setPanelBehavior({ openPanelOnActionClick: true })
+ .catch((error: unknown) => console.error('TechTerm: サイドパネルを設定できませんでした。', error));
diff --git a/packages/browser/src/category-settings.ts b/packages/browser/src/category-settings.ts
new file mode 100644
index 0000000..6397d55
--- /dev/null
+++ b/packages/browser/src/category-settings.ts
@@ -0,0 +1,54 @@
+import { termCategories, type TermCategory } from '@techword/core';
+
+const keyFor = (id: TermCategory): string => `termCategory.${id}`;
+export type CategorySettings = Record;
+export const defaultCategorySettings = (): CategorySettings =>
+ Object.fromEntries(termCategories.map(({ id }) => [id, true])) as CategorySettings;
+
+/** カテゴリごとに保存し、別カテゴリの同時更新による上書きを防ぐ。 */
+export async function setCategoryEnabled(id: TermCategory, enabled: boolean): Promise {
+ await chrome.storage.sync.set({ [keyFor(id)]: enabled });
+}
+
+/** 監視を先に開始し、初回読み込み中の変更も取りこぼさない。 */
+export function observeCategorySettings(
+ listener: (settings: CategorySettings) => void,
+ onError: (error: unknown) => void,
+): () => void {
+ const settings = defaultCategorySettings();
+ const changedDuringLoad = new Set();
+ let loading = true;
+ let active = true;
+ const handleChange = (changes: Record, area: string): void => {
+ if (area !== 'sync') return;
+ let changed = false;
+ for (const { id } of termCategories) {
+ const update = changes[keyFor(id)];
+ if (!update) continue;
+ if (loading) changedDuringLoad.add(id);
+ settings[id] = update.newValue !== false;
+ changed = true;
+ }
+ if (changed && !loading) listener({ ...settings });
+ };
+ chrome.storage.onChanged.addListener(handleChange);
+ void chrome.storage.sync.get(termCategories.map(({ id }) => keyFor(id)))
+ .then((values) => {
+ if (!active) return;
+ for (const { id } of termCategories) {
+ if (!changedDuringLoad.has(id)) settings[id] = values[keyFor(id)] !== false;
+ }
+ loading = false;
+ listener({ ...settings });
+ })
+ .catch((error: unknown) => {
+ if (!active) return;
+ loading = false;
+ listener({ ...settings });
+ onError(error);
+ });
+ return () => {
+ active = false;
+ chrome.storage.onChanged.removeListener(handleChange);
+ };
+}
diff --git a/packages/browser/src/content.ts b/packages/browser/src/content.ts
index a3073a9..752e16e 100644
--- a/packages/browser/src/content.ts
+++ b/packages/browser/src/content.ts
@@ -1,16 +1,11 @@
import { Dictionary, builtinTerms, type Match } from '@techword/core';
+import { observeCategorySettings } from './category-settings';
+import { IncrementalHighlighter, isSearchableText } from './highlighter';
+import { getFavoriteIds, toggleFavorite, watchFavoriteIds } from './favorites';
-const dictionary = new Dictionary(builtinTerms);
-
+let dictionary = new Dictionary([]);
+let highlighter: IncrementalHighlighter | undefined;
installHighlightStyle();
-highlight();
-const observer = new MutationObserver(highlight);
-const config = {
- childList: true, // 子要素の追加・削除を監視
- subtree: true, // 子孫要素(さらに下の階層)まで含めて監視する場合に true
- characterData: true, // テキストノードの変更を監視
-};
-observer.observe(document.body, config);
/** ホバーしてから出すまでの待ち時間。すぐ出すとポインタを動かすたびに点滅する。 */
const HOVER_DELAY_MS = 250;
@@ -18,19 +13,53 @@ const HOVER_DELAY_MS = 250;
const tooltip = createTooltip();
let hoverTimer: number | undefined;
let currentId: string | undefined;
+let currentFavoriteButton: HTMLButtonElement | undefined;
+let favoriteIds = new Set();
+let pointerInsideTooltip = false;
+
+observeCategorySettings((settings) => {
+ dictionary = new Dictionary(builtinTerms.filter((entry) => settings[entry.category ?? 'basics']));
+ window.clearTimeout(hoverTimer);
+ hide();
+ if (highlighter) highlighter.setDictionary(dictionary);
+ else highlighter = new IncrementalHighlighter(dictionary);
+}, (error) => console.error('TechTerm: 表示設定を読み込めませんでした。', error));
+
+void getFavoriteIds()
+ .then((ids) => {
+ favoriteIds = new Set(ids);
+ refreshCurrentFavoriteButton();
+ })
+ .catch((error: unknown) => console.error('TechTerm: お気に入りを読み込めませんでした。', error));
+
+watchFavoriteIds((ids) => {
+ favoriteIds = new Set(ids);
+ refreshCurrentFavoriteButton();
+});
document.addEventListener('mousemove', (event) => {
window.clearTimeout(hoverTimer);
+ if (event.composedPath().includes(tooltip.host) || pointerInsideTooltip) return;
hoverTimer = window.setTimeout(() => handleHover(event), HOVER_DELAY_MS);
});
document.addEventListener('scroll', hide, { passive: true, capture: true });
window.addEventListener('blur', hide);
+tooltip.host.addEventListener('pointerenter', () => {
+ pointerInsideTooltip = true;
+ window.clearTimeout(hoverTimer);
+});
+tooltip.host.addEventListener('pointerleave', () => {
+ pointerInsideTooltip = false;
+ hide();
+});
+
function handleHover(event: MouseEvent): void {
if (tooltip.host.style.display === 'block' && tooltip.host.matches(':hover')) return; // ツールチップ上にマウスがあるときはツールチップを消さないようにする
const caret = caretFromPoint(event.clientX, event.clientY);
if (!caret || caret.node.nodeType !== Node.TEXT_NODE) return hide();
+ if (!isSearchableText(caret.node as Text)) return hide();
const text = caret.node.textContent ?? '';
const hit = dictionary.findAt(text, caret.offset);
if (!hit) return hide();
@@ -44,6 +73,8 @@ function handleHover(event: MouseEvent): void {
function hide(): void {
currentId = undefined;
+ currentFavoriteButton = undefined;
+ pointerInsideTooltip = false;
tooltip.host.style.display = 'none';
}
@@ -68,10 +99,37 @@ function render({ entry }: Match): void {
const { panel } = tooltip;
panel.replaceChildren();
+ const header = document.createElement('div');
+ header.className = 'tw-header';
+
const head = document.createElement('div');
head.className = 'tw-head';
head.textContent = entry.term;
- panel.append(head);
+
+ const favoriteButton = document.createElement('button');
+ favoriteButton.type = 'button';
+ favoriteButton.className = 'tw-favorite';
+ updateFavoriteButton(favoriteButton, entry.id);
+ favoriteButton.addEventListener('click', async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ favoriteButton.disabled = true;
+
+ try {
+ const isFavorite = await toggleFavorite(entry.id);
+ if (isFavorite) favoriteIds.add(entry.id);
+ else favoriteIds.delete(entry.id);
+ updateFavoriteButton(favoriteButton, entry.id);
+ } catch (error: unknown) {
+ console.error('TechTerm: お気に入りを更新できませんでした。', error);
+ } finally {
+ favoriteButton.disabled = false;
+ }
+ });
+ currentFavoriteButton = favoriteButton;
+
+ header.append(head, favoriteButton);
+ panel.append(header);
const short = document.createElement('div');
short.className = 'tw-short';
@@ -104,6 +162,7 @@ function render({ entry }: Match): void {
/** ページ側の CSS に影響されないよう Shadow DOM に閉じ込める。 */
function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
const host = document.createElement('div');
+ host.dataset.techtermUi = '';
host.style.cssText = 'position:fixed;z-index:2147483647;display:none;';
const root = host.attachShadow({ mode: 'closed' });
@@ -115,7 +174,16 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
box-shadow: 0 6px 20px rgba(0,0,0,.35);
font: 13px/1.6 -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif;
}
- .tw-head { font-weight: 700; margin-bottom: 2px; }
+ .tw-header { display: flex; align-items: center; gap: 10px; margin-bottom: 2px; }
+ .tw-head { flex: 1; min-width: 0; font-weight: 700; }
+ .tw-favorite {
+ display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0;
+ border: 1px solid #596276; border-radius: 7px; background: #2b3241; color: #ffd166;
+ cursor: pointer; font: 20px/1 sans-serif;
+ }
+ .tw-favorite:hover { background: #394256; }
+ .tw-favorite:focus-visible { outline: 2px solid #7fb2ff; outline-offset: 2px; }
+ .tw-favorite:disabled { cursor: wait; opacity: .65; }
.tw-short { color: #f2f2f2; }
.tw-detail { margin-top: 6px; color: #b9c0cf; }
.tw-example {
@@ -133,6 +201,21 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
return { host, panel };
}
+function updateFavoriteButton(button: HTMLButtonElement, id: string): void {
+ const isFavorite = favoriteIds.has(id);
+ const label = isFavorite ? 'お気に入りから削除' : 'お気に入りに追加';
+ button.textContent = isFavorite ? '★' : '☆';
+ button.setAttribute('aria-pressed', String(isFavorite));
+ button.setAttribute('aria-label', label);
+ button.title = label;
+}
+
+function refreshCurrentFavoriteButton(): void {
+ if (currentId && currentFavoriteButton) {
+ updateFavoriteButton(currentFavoriteButton, currentId);
+ }
+}
+
/** Chrome (caretRangeFromPoint) と Firefox (caretPositionFromPoint) の差を吸収する。 */
function caretFromPoint(x: number, y: number): { node: Node; offset: number } | undefined {
const doc = document as Document & {
@@ -152,33 +235,11 @@ function caretFromPoint(x: number, y: number): { node: Node; offset: number } |
return undefined;
}
-function highlight(): void {
- let ranges: Range[] = [];
- const walker = document.createTreeWalker(
- document.body, // body以下のノードを走査する
- NodeFilter.SHOW_TEXT, //テキストノードのみを対象
- );
-
- while (walker.nextNode() !== null) {
- const nodeTagName = walker.currentNode.parentElement?.tagName;
- if (nodeTagName === 'SCRIPT' || nodeTagName === 'STYLE') continue; // 負荷軽減のためにスクリプトとスタイルの中身は無視する
- const textNodes = dictionary.findAll(walker.currentNode.textContent ?? '');
- for (const {start, end} of textNodes) {
- const range = new Range();
- range.setStart(walker.currentNode, start);
- range.setEnd(walker.currentNode, end);
- ranges.push(range);
- }
- }
- const highlightRange = new Highlight(...ranges); //スプレッド構文でranges配列を展開し引数として渡す
- CSS.highlights.set("tw-highlight", highlightRange);
-}
-
function installHighlightStyle(): void {
const style = document.createElement("style");
style.textContent = `
::highlight(tw-highlight) {
- background-color: #ffff00;
+ background-color: rgba(255, 220, 100, 0.2);
}`;
document.head.append(style);
}
diff --git a/packages/browser/src/favorites.ts b/packages/browser/src/favorites.ts
new file mode 100644
index 0000000..891451b
--- /dev/null
+++ b/packages/browser/src/favorites.ts
@@ -0,0 +1,56 @@
+export const FAVORITES_STORAGE_KEY = 'favoriteTermIds';
+
+type FavoriteIdsListener = (ids: string[]) => void;
+
+function sanitizeFavoriteIds(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return [
+ ...new Set(value.filter((id): id is string => typeof id === 'string' && id.length > 0)),
+ ];
+}
+
+export async function getFavoriteIds(): Promise {
+ const result = await chrome.storage.sync.get(FAVORITES_STORAGE_KEY);
+ return sanitizeFavoriteIds(result[FAVORITES_STORAGE_KEY]);
+}
+
+async function setFavoriteIds(ids: string[]): Promise {
+ await chrome.storage.sync.set({
+ [FAVORITES_STORAGE_KEY]: sanitizeFavoriteIds(ids),
+ });
+}
+
+export async function toggleFavorite(id: string): Promise {
+ const ids = await getFavoriteIds();
+ const nextIsFavorite = !ids.includes(id);
+ const nextIds = nextIsFavorite
+ ? [...ids, id]
+ : ids.filter((currentId) => currentId !== id);
+
+ await setFavoriteIds(nextIds);
+ return nextIsFavorite;
+}
+
+export async function removeFavorite(id: string): Promise {
+ const ids = await getFavoriteIds();
+ await setFavoriteIds(ids.filter((currentId) => currentId !== id));
+}
+
+export async function clearFavorites(): Promise {
+ await setFavoriteIds([]);
+}
+
+export function watchFavoriteIds(listener: FavoriteIdsListener): () => void {
+ const handleChange = (
+ changes: Record,
+ areaName: string,
+ ): void => {
+ if (areaName !== 'sync') return;
+ const change = changes[FAVORITES_STORAGE_KEY];
+ if (!change) return;
+ listener(sanitizeFavoriteIds(change.newValue));
+ };
+
+ chrome.storage.onChanged.addListener(handleChange);
+ return () => chrome.storage.onChanged.removeListener(handleChange);
+}
diff --git a/packages/browser/src/highlighter.ts b/packages/browser/src/highlighter.ts
new file mode 100644
index 0000000..f7486d3
--- /dev/null
+++ b/packages/browser/src/highlighter.ts
@@ -0,0 +1,110 @@
+import type { Dictionary } from '@techword/core';
+
+/** スクリプト・入力欄・拡張機能自身のUIは検索しない。 */
+export function isSearchableText(node: Text): boolean {
+ return Boolean(document.body?.contains(node)) &&
+ !node.parentElement?.closest('script, style, textarea, input, [contenteditable]:not([contenteditable="false"]), [data-techterm-ui]');
+}
+
+/** DOMの変更箇所だけを検索し、変更のないRangeは保持する。 */
+export class IncrementalHighlighter {
+ private readonly ranges = new Map();
+ private readonly highlight = typeof Highlight === 'undefined' ? undefined : new Highlight();
+ private readonly pending = new Set();
+ private readonly removed = new Set();
+ private frame: number | undefined;
+ private readonly observer: MutationObserver;
+
+ constructor(private dictionary: Dictionary) {
+ if (this.highlight) CSS.highlights.set('tw-highlight', this.highlight);
+ this.observer = new MutationObserver((records) => {
+ for (const record of records) {
+ if (record.type === 'characterData' || record.type === 'attributes') {
+ this.pending.add(record.target);
+ } else {
+ for (const node of record.removedNodes) this.removed.add(node);
+ for (const node of record.addedNodes) this.pending.add(node);
+ }
+ }
+ if (this.frame === undefined) this.frame = requestAnimationFrame(() => this.flush());
+ });
+ this.observer.observe(document.documentElement, {
+ childList: true, subtree: true, characterData: true,
+ attributes: true, attributeFilter: ['contenteditable'],
+ });
+ this.rescan();
+ }
+
+ setDictionary(dictionary: Dictionary): void {
+ this.dictionary = dictionary;
+ // 設定変更時だけ全体を再検索。通常のDOM更新では行わない。
+ this.rescan();
+ }
+
+ disconnect(): void {
+ this.observer.disconnect();
+ if (this.frame !== undefined) cancelAnimationFrame(this.frame);
+ this.ranges.clear();
+ this.pending.clear();
+ this.removed.clear();
+ this.highlight?.clear();
+ CSS.highlights?.delete('tw-highlight');
+ }
+
+ private rescan(): void {
+ if (this.frame !== undefined) cancelAnimationFrame(this.frame);
+ this.frame = undefined;
+ this.pending.clear();
+ this.removed.clear();
+ this.observer.takeRecords();
+ this.ranges.clear();
+ this.highlight?.clear();
+ if (document.body) this.visit(document.body, (node) => this.update(node));
+ }
+
+ private flush(): void {
+ this.frame = undefined;
+ // 移動したノードは旧Rangeを消してから、最終的な場所で再検索する。
+ for (const root of this.topLevelRoots(this.removed)) this.visit(root, (node) => this.remove(node));
+ for (const root of this.topLevelRoots(this.pending)) this.visit(root, (node) => this.update(node));
+ this.removed.clear();
+ this.pending.clear();
+ }
+
+ private topLevelRoots(nodes: Set): Node[] {
+ return [...nodes].filter((node) => {
+ for (let parent = node.parentNode; parent; parent = parent.parentNode) {
+ if (nodes.has(parent)) return false;
+ }
+ return true;
+ });
+ }
+
+ private visit(root: Node, action: (node: Text) => void): void {
+ if (root.nodeType === Node.TEXT_NODE) {
+ action(root as Text);
+ return;
+ }
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ while (walker.nextNode()) action(walker.currentNode as Text);
+ }
+
+ private remove(node: Text): void {
+ for (const range of this.ranges.get(node) ?? []) this.highlight?.delete(range);
+ this.ranges.delete(node);
+ }
+
+ private update(node: Text): void {
+ this.remove(node);
+ if (!this.highlight || !isSearchableText(node)) return;
+ const ranges: Range[] = [];
+ for (const { start, end } of this.dictionary.findAll(node.data)) {
+ const range = new Range();
+ range.setStart(node, start);
+ range.setEnd(node, end);
+ ranges.push(range);
+ this.highlight.add(range);
+ }
+ if (ranges.length) this.ranges.set(node, ranges);
+ }
+}
diff --git a/packages/browser/src/sidepanel.ts b/packages/browser/src/sidepanel.ts
new file mode 100644
index 0000000..c21706e
--- /dev/null
+++ b/packages/browser/src/sidepanel.ts
@@ -0,0 +1,178 @@
+import { defaultCategorySettings, observeCategorySettings, setCategoryEnabled } from './category-settings';
+import { builtinTerms, termCategories, type TermCategory, type TermEntry } from '@techword/core';
+import {
+ clearFavorites,
+ getFavoriteIds,
+ removeFavorite,
+ watchFavoriteIds,
+} from './favorites';
+
+const favoriteList = requireElement('favorites');
+const emptyMessage = requireElement('empty');
+const count = requireElement('count');
+const clearButton = requireElement('clear-favorites');
+const errorMessage = requireElement('error');
+const termsById = new Map(builtinTerms.map((entry) => [entry.id, entry]));
+
+let currentFavoriteIds: string[] = [];
+
+clearButton.addEventListener('click', async () => {
+ if (currentFavoriteIds.length === 0) return;
+ if (!window.confirm('お気に入りをすべて削除しますか?')) return;
+
+ clearButton.disabled = true;
+ hideError();
+ try {
+ await clearFavorites();
+ renderFavorites([]);
+ } catch (error: unknown) {
+ showError('お気に入りを削除できませんでした。', error);
+ clearButton.disabled = false;
+ }
+});
+
+watchFavoriteIds(renderFavorites);
+void loadFavorites();
+
+async function loadFavorites(): Promise {
+ hideError();
+ try {
+ renderFavorites(await getFavoriteIds());
+ } catch (error: unknown) {
+ renderFavorites([]);
+ showError('お気に入りを読み込めませんでした。', error);
+ }
+}
+
+function renderFavorites(ids: string[]): void {
+ currentFavoriteIds = ids;
+ const entries = ids
+ .map((id) => termsById.get(id))
+ .filter((entry): entry is TermEntry => entry !== undefined);
+
+ favoriteList.replaceChildren(...entries.map(createFavoriteItem));
+ favoriteList.hidden = entries.length === 0;
+ emptyMessage.hidden = entries.length !== 0;
+ count.textContent = `${entries.length}件`;
+ clearButton.disabled = ids.length === 0;
+}
+
+function createFavoriteItem(entry: TermEntry): HTMLLIElement {
+ const item = document.createElement('li');
+ item.className = 'favorite-item';
+
+ const body = document.createElement('div');
+ body.className = 'favorite-body';
+
+ const term = document.createElement('h2');
+ term.textContent = entry.term;
+
+ const description = document.createElement('p');
+ description.textContent = entry.short;
+
+ body.append(term, description);
+
+ if (entry.link) {
+ const link = document.createElement('a');
+ link.href = entry.link;
+ link.target = '_blank';
+ link.rel = 'noreferrer noopener';
+ link.textContent = '詳しく';
+ body.append(link);
+ }
+
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.className = 'remove-favorite';
+ removeButton.textContent = '削除';
+ removeButton.setAttribute('aria-label', `${entry.term}をお気に入りから削除`);
+ removeButton.addEventListener('click', async () => {
+ removeButton.disabled = true;
+ hideError();
+ try {
+ await removeFavorite(entry.id);
+ renderFavorites(currentFavoriteIds.filter((id) => id !== entry.id));
+ } catch (error: unknown) {
+ showError(`${entry.term}を削除できませんでした。`, error);
+ removeButton.disabled = false;
+ }
+ });
+
+ item.append(body, removeButton);
+ return item;
+}
+
+function requireElement(id: string): T {
+ const element = document.getElementById(id);
+ if (!element) throw new Error(`Missing element: #${id}`);
+ return element as T;
+}
+
+function hideError(): void {
+ errorMessage.hidden = true;
+ errorMessage.textContent = '';
+}
+
+function showError(message: string, error: unknown): void {
+ console.error(`TechTerm: ${message}`, error);
+ errorMessage.textContent = message;
+ errorMessage.hidden = false;
+}
+
+const categoryList = requireElement('term-categories');
+const settingsStatus = requireElement('settings-status');
+const settingsError = requireElement('settings-error');
+let categorySettings = defaultCategorySettings();
+const categoryInputs = new Map();
+const savingCategories = new Set();
+
+for (const { id, label } of termCategories) {
+ const row = document.createElement('label');
+ row.className = 'category-row';
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.disabled = true;
+ const name = document.createElement('span');
+ name.textContent = label;
+ const count = document.createElement('span');
+ count.className = 'category-count';
+ count.textContent = `${builtinTerms.filter((entry) => (entry.category ?? 'basics') === id).length}語`;
+ row.append(input, name, count);
+ categoryList.append(row);
+ categoryInputs.set(id, input);
+ input.addEventListener('change', async () => {
+ const enabled = input.checked;
+ savingCategories.add(id);
+ input.disabled = true;
+ settingsError.hidden = true;
+ settingsStatus.textContent = '保存中…';
+ try {
+ await setCategoryEnabled(id, enabled);
+ // 確定した表示状態はstorage.onChangedから反映する。
+ settingsStatus.textContent = '保存しました。開いているページにも反映されます。';
+ } catch (error: unknown) {
+ input.checked = categorySettings[id];
+ settingsStatus.textContent = '';
+ settingsError.textContent = '表示設定を保存できませんでした。もう一度お試しください。';
+ settingsError.hidden = false;
+ console.error('TechTerm: 表示設定を保存できませんでした。', error);
+ } finally {
+ savingCategories.delete(id);
+ input.disabled = false;
+ }
+ });
+}
+
+observeCategorySettings((settings) => {
+ categorySettings = settings;
+ for (const [id, input] of categoryInputs) {
+ input.checked = settings[id];
+ input.disabled = savingCategories.has(id);
+ }
+ const enabledCount = builtinTerms.filter((entry) => settings[entry.category ?? 'basics']).length;
+ settingsStatus.textContent = `${enabledCount} / ${builtinTerms.length}語が有効`;
+}, (error) => {
+ settingsError.textContent = '表示設定を読み込めませんでした。';
+ settingsError.hidden = false;
+ console.error('TechTerm: 表示設定を読み込めませんでした。', error);
+});
diff --git a/packages/browser/tests/browser.test.cjs b/packages/browser/tests/browser.test.cjs
new file mode 100644
index 0000000..dc52429
--- /dev/null
+++ b/packages/browser/tests/browser.test.cjs
@@ -0,0 +1,171 @@
+const assert = require('node:assert/strict');
+const { test } = require('node:test');
+const path = require('node:path');
+const fs = require('node:fs');
+const { buildSync } = require('esbuild');
+const { JSDOM } = require('jsdom');
+const root = path.resolve(__dirname, '..');
+function bundle(file, name = 'TestModule') {
+ return buildSync({ entryPoints: [path.join(root, 'src', file)], bundle: true, write: false, format: 'iife', globalName: name }).outputFiles[0].text + `\nglobalThis.${name} = ${name};`;
+}
+const highlighterCode = bundle('highlighter.ts');
+const settingsCode = bundle('category-settings.ts');
+const contentCode = bundle('content.ts');
+const sidepanelCode = bundle('sidepanel.ts');
+function dom(html = 'JavaScript
') {
+ const d = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true });
+ const observers = [];
+ const NativeObserver = d.window.MutationObserver;
+ d.window.MutationObserver = class extends NativeObserver {
+ constructor(callback) { super(callback); observers.push(this); }
+ };
+ const close = d.window.close.bind(d.window);
+ d.window.close = () => { observers.forEach(observer => observer.disconnect()); close(); };
+ d.window.Highlight = class extends Set {};
+ d.window.CSS = { highlights: new Map() };
+ return d;
+}
+const settle = async (w) => { await new Promise(resolve => w.requestAnimationFrame(() => w.requestAnimationFrame(resolve))); };
+function storage(w, initial = {}, options = {}) {
+ const data = { ...initial };
+ const listeners = new Set();
+ const emit = (changes, area = 'sync') => listeners.forEach(fn => fn(changes, area));
+ w.chrome = { storage: { onChanged: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) }, sync: {
+ get: async () => {
+ const snapshot = { ...data };
+ if (options.beforeRead) await options.beforeRead(emit);
+ return snapshot;
+ },
+ set: async (values) => {
+ if (options.failWrite) throw Error('write failed');
+ const changes = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, { oldValue: data[k], newValue: v }]));
+ Object.assign(data, values); emit(changes);
+ },
+ } } };
+ return { data, emit };
+}
+
+test('1,000段落のうち変更・追加されたテキストだけを検索し、削除Rangeを除去する', async () => {
+ const d = dom('' + 'JavaScript
'.repeat(1000) + '');
+ const w = d.window;
+ try {
+ w.eval(highlighterCode);
+ const calls = [];
+ const dictionary = { findAll: text => { calls.push(text); return text ? [{ start: 0, end: text.length }] : []; } };
+ const h = new w.TestModule.IncrementalHighlighter(dictionary);
+ const highlight = w.CSS.highlights.get('tw-highlight');
+ assert.equal(calls.length, 1000);
+ const unchanged = [...highlight][1];
+ calls.length = 0;
+ w.document.querySelector('p').firstChild.data = 'TypeScript';
+ await settle(w);
+ assert.deepEqual(calls, ['TypeScript']);
+ assert.ok(highlight.has(unchanged));
+ calls.length = 0;
+ const wrapper = w.document.createElement('section');
+ wrapper.innerHTML = 'React
Vue
';
+ w.document.querySelector('main').append(wrapper);
+ wrapper.firstChild.firstChild.data = 'Nuxt';
+ await settle(w);
+ assert.deepEqual(calls, ['Nuxt', 'Vue']);
+ calls.length = 0;
+ wrapper.remove();
+ await settle(w);
+ assert.equal(highlight.size, 1000);
+ assert.equal(calls.length, 0);
+ h.disconnect();
+ } finally { w.close(); }
+});
+
+test('ノード移動・body差し替え・編集領域の切り替えでも古いRangeを残さない', async () => {
+ const d = dom(''); const w = d.window;
+ try {
+ w.eval(highlighterCode);
+ const h = new w.TestModule.IncrementalHighlighter({ findAll: text => [{ start: 0, end: text.length }] });
+ const ranges = w.CSS.highlights.get('tw-highlight');
+ w.document.querySelector('aside').append(w.document.querySelector('p'));
+ await settle(w); assert.equal(ranges.size, 1);
+ w.document.querySelector('aside').setAttribute('contenteditable', 'true');
+ await settle(w); assert.equal(ranges.size, 0);
+ w.document.querySelector('aside').removeAttribute('contenteditable');
+ await settle(w); assert.equal(ranges.size, 1);
+ const body = w.document.createElement('body'); body.innerHTML = 'Git
';
+ w.document.body.replaceWith(body); await settle(w);
+ assert.equal(ranges.size, 1); assert.equal([...ranges][0].toString(), 'Git');
+ h.disconnect();
+ } finally { w.close(); }
+});
+
+test('設定の初回読込中に受信した変更が古い値で上書きされない', async () => {
+ const d = dom(); const w = d.window;
+ try {
+ storage(w, {}, { beforeRead: async emit => emit({ 'termCategory.javascript': { newValue: false } }) });
+ w.eval(settingsCode);
+ let received;
+ const stop = w.TestModule.observeCategorySettings(value => { received = value; }, assert.fail);
+ await settle(w);
+ assert.equal(received.javascript, false);
+ assert.equal(received.basics, true);
+ await w.TestModule.setCategoryEnabled('basics', false);
+ assert.equal(received.javascript, false); assert.equal(received.basics, false);
+ stop();
+ } finally { w.close(); }
+});
+
+test('カテゴリOFFで表示済みの説明とハイライトが消え、ONで戻る。お気に入りは保持', async () => {
+ const d = dom('JavaScript
'); const w = d.window;
+ try {
+ const { data } = storage(w, { favoriteTermIds: ['javascript'] });
+ const node = w.document.querySelector('p').firstChild;
+ w.document.caretRangeFromPoint = () => { const r = w.document.createRange(); r.setStart(node, 2); return r; };
+ w.Range.prototype.getBoundingClientRect = () => ({ left: 10, bottom: 20, top: 5 });
+ w.eval(contentCode); await settle(w);
+ const hover = async () => { w.document.dispatchEvent(new w.MouseEvent('mousemove', { clientX: 20, clientY: 10 })); await new Promise(r => w.setTimeout(r, 280)); };
+ const host = w.document.querySelector('[data-techterm-ui]');
+ assert.equal(w.CSS.highlights.get('tw-highlight').size, 1);
+ await hover(); assert.equal(host.style.display, 'block');
+ await w.chrome.storage.sync.set({ 'termCategory.javascript': false });
+ assert.equal(host.style.display, 'none');
+ assert.equal(w.CSS.highlights.get('tw-highlight').size, 0);
+ await hover(); assert.equal(host.style.display, 'none');
+ await w.chrome.storage.sync.set({ 'termCategory.javascript': true });
+ assert.equal(w.CSS.highlights.get('tw-highlight').size, 1);
+ await hover(); assert.equal(host.style.display, 'block');
+ assert.deepEqual(data.favoriteTermIds, ['javascript']);
+ } finally { w.close(); }
+});
+
+test('サイドパネルに7カテゴリを表示し、保存と失敗時の復元を行う', async () => {
+ const d = dom(fs.readFileSync(path.join(root, 'sidepanel.html'), 'utf8')); const w = d.window;
+ try {
+ const options = {}; const { data, emit } = storage(w, { 'termCategory.javascript': false }, options);
+ w.eval(sidepanelCode); await settle(w);
+ const inputs = [...w.document.querySelectorAll('#term-categories input')];
+ assert.equal(inputs.length, 7); assert.equal(inputs[1].checked, false);
+ inputs[0].click(); await settle(w);
+ assert.equal(data['termCategory.basics'], false);
+ assert.equal(data['termCategory.javascript'], false);
+ const errors = []; w.console.error = (...args) => errors.push(args);
+ options.failWrite = true; inputs[0].click(); await settle(w);
+ assert.equal(errors.length, 1);
+ assert.equal(inputs[0].checked, false); assert.equal(inputs[0].disabled, false);
+ assert.equal(w.document.querySelector('#settings-error').hidden, false);
+ emit({ 'termCategory.javascript': { newValue: true } });
+ assert.equal(inputs[1].checked, true);
+ } finally { w.close(); }
+});
+
+test('全カテゴリOFFの保存状態で起動すると、後から追加した用語も表示しない', async () => {
+ const d = dom('JavaScript Git API
'); const w = d.window;
+ try {
+ const ids = ['basics', 'javascript', 'frameworks', 'html-css', 'development', 'network-data', 'design-performance'];
+ storage(w, Object.fromEntries(ids.map(id => ['termCategory.' + id, false])));
+ w.eval(contentCode); await settle(w);
+ assert.equal(w.CSS.highlights.get('tw-highlight').size, 0);
+ const p = w.document.createElement('p'); p.textContent = 'React'; w.document.body.append(p);
+ await settle(w);
+ assert.equal(w.CSS.highlights.get('tw-highlight').size, 0);
+ await w.chrome.storage.sync.set({ 'termCategory.frameworks': true });
+ assert.equal(w.CSS.highlights.get('tw-highlight').size, 1);
+ } finally { w.close(); }
+});
diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json
new file mode 100644
index 0000000..1290cee
--- /dev/null
+++ b/packages/browser/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["chrome"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/core/src/categories.ts b/packages/core/src/categories.ts
new file mode 100644
index 0000000..3d50928
--- /dev/null
+++ b/packages/core/src/categories.ts
@@ -0,0 +1,12 @@
+/** 表示設定で使う安定したカテゴリIDと表示名。 */
+export const termCategories = [
+ { id: 'basics', label: 'プログラミング基礎' },
+ { id: 'javascript', label: 'JavaScript・TypeScript' },
+ { id: 'frameworks', label: 'React・Vue' },
+ { id: 'html-css', label: 'HTML・CSS' },
+ { id: 'development', label: 'Git・開発ツール' },
+ { id: 'network-data', label: '通信・データベース' },
+ { id: 'design-performance', label: '設計・性能' },
+] as const;
+
+export type TermCategory = (typeof termCategories)[number]['id'];
diff --git a/packages/core/src/data/terms.json b/packages/core/src/data/terms.json
index 86c7ba5..ed57fdd 100644
--- a/packages/core/src/data/terms.json
+++ b/packages/core/src/data/terms.json
@@ -1,6 +1,7 @@
[
{
"id": "closure",
+ "category": "javascript",
"term": "closure",
"match": ["クロージャ", "クロージャー"],
"short": "自分が作られた場所の変数を覚えている関数。",
@@ -10,6 +11,7 @@
},
{
"id": "idempotent",
+ "category": "design-performance",
"term": "idempotent",
"match": ["冪等", "べき等", "idempotency"],
"short": "何回実行しても結果が同じになる性質。",
@@ -17,6 +19,7 @@
},
{
"id": "race-condition",
+ "category": "design-performance",
"term": "race condition",
"match": ["競合状態", "レースコンディション"],
"short": "処理の実行順序次第で結果が変わってしまうバグ。",
@@ -24,6 +27,7 @@
},
{
"id": "memoization",
+ "category": "design-performance",
"term": "memoization",
"match": ["メモ化", "memoize", "memo"],
"short": "一度計算した結果を覚えておいて使い回すこと。",
@@ -31,6 +35,7 @@
},
{
"id": "gc",
+ "category": "design-performance",
"term": "garbage collection",
"match": ["GC", "ガベージコレクション", "ガベコレ"],
"short": "使われなくなったメモリを自動で回収する仕組み。",
@@ -38,6 +43,7 @@
},
{
"id": "immutable",
+ "category": "design-performance",
"term": "immutable",
"match": ["イミュータブル", "不変", "immutability"],
"short": "作った後に中身を書き換えられないデータ。",
@@ -45,6 +51,7 @@
},
{
"id": "dependency-injection",
+ "category": "design-performance",
"term": "dependency injection",
"match": ["DI", "依存性注入"],
"short": "使う部品を自分で作らず、外から渡してもらう設計。",
@@ -52,6 +59,7 @@
},
{
"id": "middleware",
+ "category": "network-data",
"term": "middleware",
"match": ["ミドルウェア"],
"short": "リクエストと本処理の間に挟まる共通処理。",
@@ -59,6 +67,7 @@
},
{
"id": "polyfill",
+ "category": "javascript",
"term": "polyfill",
"match": ["ポリフィル"],
"short": "古い環境に新しい機能を後付けするコード。",
@@ -66,6 +75,7 @@
},
{
"id": "hoisting",
+ "category": "javascript",
"term": "hoisting",
"match": ["巻き上げ", "ホイスティング"],
"short": "宣言がスコープの先頭に持ち上げられる挙動。",
@@ -74,6 +84,7 @@
},
{
"id": "use-state",
+ "category": "frameworks",
"term": "useState",
"match": ["ユーズステート"],
"short": "React で「変わる値」を持つためのフック。",
@@ -84,6 +95,7 @@
},
{
"id": "use-effect",
+ "category": "frameworks",
"term": "useEffect",
"short": "React で描画のあとに副作用を走らせるフック。",
"detail": "通信・購読・DOM 操作など、描画そのものではない処理を置く。第 2 引数の依存配列に入れた値が変わったときだけ再実行される。",
@@ -92,6 +104,7 @@
},
{
"id": "promise",
+ "category": "javascript",
"term": "Promise",
"match": ["プロミス"],
"short": "「あとで結果が入る箱」を表すオブジェクト。",
@@ -100,6 +113,7 @@
},
{
"id": "generic",
+ "category": "javascript",
"term": "generics",
"match": ["ジェネリクス", "generic", "型引数"],
"short": "型を後から差し込めるようにする仕組み。",
@@ -107,6 +121,7 @@
},
{
"id": "type-guard",
+ "category": "javascript",
"term": "type guard",
"match": ["型ガード"],
"short": "実行時のチェックで型を絞り込む書き方。",
@@ -115,6 +130,7 @@
},
{
"id": "orm",
+ "category": "network-data",
"term": "ORM",
"match": ["オーアールエム", "object relational mapping"],
"short": "DB のテーブルをコードのオブジェクトとして扱う道具。",
@@ -122,6 +138,7 @@
},
{
"id": "n-plus-one",
+ "category": "network-data",
"term": "N+1 problem",
"match": ["N+1", "エヌプラスワン"],
"short": "一覧を出すたびに件数ぶん追加クエリが飛ぶ性能問題。",
@@ -129,6 +146,7 @@
},
{
"id": "idempotency-key",
+ "category": "design-performance",
"term": "idempotency key",
"match": ["冪等キー"],
"short": "同じリクエストの二重実行を防ぐための識別子。",
@@ -136,6 +154,7 @@
},
{
"id": "cors",
+ "category": "network-data",
"term": "CORS",
"match": ["オリジン間リソース共有", "cross origin resource sharing"],
"short": "別ドメインへのブラウザからの通信を許可する仕組み。",
@@ -143,6 +162,7 @@
},
{
"id": "jwt",
+ "category": "network-data",
"term": "JWT",
"match": ["json web token", "ジョット"],
"short": "署名つきで中身を自分で検証できるトークン。",
@@ -150,6 +170,7 @@
},
{
"id": "migration",
+ "category": "network-data",
"term": "migration",
"match": ["マイグレーション", "マイグレ"],
"short": "DB のスキーマ変更を手順として記録したファイル。",
@@ -157,6 +178,7 @@
},
{
"id": "debounce",
+ "category": "design-performance",
"term": "debounce",
"match": ["デバウンス"],
"short": "連続した呼び出しを、止まってから 1 回にまとめる。",
@@ -164,6 +186,7 @@
},
{
"id": "throttle",
+ "category": "design-performance",
"term": "throttle",
"match": ["スロットル", "スロットリング", "throttling"],
"short": "呼び出しの頻度を一定間隔までに制限する。",
@@ -171,6 +194,7 @@
},
{
"id": "exponential-backoff",
+ "category": "design-performance",
"term": "exponential backoff",
"match": ["指数バックオフ", "backoff"],
"short": "リトライ間隔を 1秒, 2秒, 4秒…と伸ばしていく方式。",
@@ -178,6 +202,7 @@
},
{
"id": "single-source-of-truth",
+ "category": "design-performance",
"term": "single source of truth",
"match": ["SSOT", "信頼できる唯一の情報源"],
"short": "同じ情報の正本を 1 箇所だけに持つ設計方針。",
@@ -185,6 +210,7 @@
},
{
"id": "feature-flag",
+ "category": "design-performance",
"term": "feature flag",
"match": ["フィーチャーフラグ", "feature toggle", "機能フラグ"],
"short": "コードを出したまま機能のオン/オフを切り替える仕組み。",
@@ -192,6 +218,7 @@
},
{
"id": "cache",
+ "category": "design-performance",
"term": "cache",
"match": ["キャッシュ", "caching"],
"short": "一度取得・計算したデータを保存し、次回から素早く使う仕組み。",
@@ -199,6 +226,7 @@
},
{
"id": "api",
+ "category": "network-data",
"term": "API",
"match": ["エーピーアイ", "application programming interface"],
"short": "ソフトウェア同士が機能やデータをやり取りするための窓口。",
@@ -206,6 +234,7 @@
},
{
"id": "refactoring",
+ "category": "design-performance",
"term": "refactoring",
"match": ["リファクタリング", "リファクタ"],
"short": "動作を変えずに、コードを読みやすく整理すること。",
@@ -213,6 +242,7 @@
},
{
"id": "object",
+ "category": "basics",
"term": "object",
"match": ["オブジェクト"],
"short": "複数のデータや処理をひとまとまりにしたもの。",
@@ -222,6 +252,7 @@
},
{
"id": "class",
+ "category": "basics",
"term": "class",
"match": ["クラス"],
"short": "同じ種類のオブジェクトを作るための設計図。",
@@ -231,6 +262,7 @@
},
{
"id": "instance",
+ "category": "basics",
"term": "instance",
"match": ["インスタンス"],
"short": "クラスなどの設計をもとに実際に作られたオブジェクト。",
@@ -239,6 +271,7 @@
},
{
"id": "key",
+ "category": "basics",
"term": "key",
"match": ["キー", "property name", "プロパティ名"],
"short": "オブジェクトなどから値を取り出すための名前。",
@@ -247,6 +280,7 @@
},
{
"id": "value",
+ "category": "basics",
"term": "value",
"match": ["バリュー", "値"],
"short": "変数やキーに対応して保存されているデータ。",
@@ -255,6 +289,7 @@
},
{
"id": "path",
+ "category": "development",
"term": "path",
"match": ["パス", "ファイルパス"],
"short": "ファイルやディレクトリの場所を表す文字列。",
@@ -263,6 +298,7 @@
},
{
"id": "directory",
+ "category": "development",
"term": "directory",
"match": ["ディレクトリ", "folder", "フォルダ"],
"short": "ファイルや別のディレクトリをまとめて置く場所。",
@@ -271,6 +307,7 @@
},
{
"id": "root-directory",
+ "category": "development",
"term": "root directory",
"match": ["ルートディレクトリ", "root folder", "ルートフォルダ"],
"short": "ディレクトリ階層のいちばん上にある場所。",
@@ -279,6 +316,7 @@
},
{
"id": "current-directory",
+ "category": "development",
"term": "current directory",
"match": ["カレントディレクトリ", "working directory", "作業ディレクトリ"],
"short": "プログラムやコマンドが現在の基準にしているディレクトリ。",
@@ -287,6 +325,7 @@
},
{
"id": "javascript",
+ "category": "javascript",
"term": "JavaScript",
"match": ["ジャバスクリプト", "JS"],
"short": "Web ページなどに動きや処理を加えるプログラミング言語。",
@@ -296,6 +335,7 @@
},
{
"id": "typescript",
+ "category": "javascript",
"term": "TypeScript",
"match": ["タイプスクリプト", "TS"],
"short": "JavaScript に型の仕組みを加えたプログラミング言語。",
@@ -305,6 +345,7 @@
},
{
"id": "nodejs",
+ "category": "javascript",
"term": "Node.js",
"match": ["Node", "NodeJS", "ノード", "ノードジェイエス"],
"short": "ブラウザの外で JavaScript を実行するための環境。",
@@ -314,6 +355,7 @@
},
{
"id": "vue",
+ "category": "frameworks",
"term": "Vue.js",
"match": ["Vue", "VueJS", "ビュー", "ビュージェイエス"],
"short": "Web の画面を部品に分けて作るための JavaScript フレームワーク。",
@@ -323,6 +365,7 @@
},
{
"id": "nextjs",
+ "category": "frameworks",
"term": "Next.js",
"match": ["Next", "NextJS", "ネクスト", "ネクストジェイエス"],
"short": "React を使った Web アプリ開発のためのフレームワーク。",
@@ -332,6 +375,7 @@
},
{
"id": "react",
+ "category": "frameworks",
"term": "React",
"match": ["React.js", "ReactJS", "リアクト"],
"short": "Web の画面をコンポーネントとして作る JavaScript ライブラリ。",
@@ -341,6 +385,7 @@
},
{
"id": "python",
+ "category": "basics",
"term": "Python",
"match": ["パイソン"],
"short": "読みやすく簡潔な文法を持つプログラミング言語。",
@@ -350,6 +395,7 @@
},
{
"id": "ruby",
+ "category": "basics",
"term": "Ruby",
"match": ["ルビー"],
"short": "人が読み書きしやすいことを重視したプログラミング言語。",
@@ -359,6 +405,7 @@
},
{
"id": "html",
+ "category": "html-css",
"term": "HTML",
"match": ["エイチティーエムエル", "HyperText Markup Language"],
"short": "Web ページの内容と構造を記述するためのマークアップ言語。",
@@ -368,6 +415,7 @@
},
{
"id": "css",
+ "category": "html-css",
"term": "CSS",
"match": ["シーエスエス", "Cascading Style Sheets"],
"short": "Web ページの見た目やレイアウトを指定するための言語。",
@@ -377,6 +425,7 @@
},
{
"id": "vscode",
+ "category": "development",
"term": "Visual Studio Code",
"match": ["VS Code", "VSCode", "ブイエスコード"],
"short": "プログラムの作成や編集に使うソースコードエディタ。",
@@ -386,6 +435,7 @@
},
{
"id": "nuxt",
+ "category": "frameworks",
"term": "Nuxt",
"match": ["Nuxt.js", "NuxtJS", "ナクスト"],
"short": "Vue を使った Web アプリ開発のためのフレームワーク。",
@@ -395,6 +445,7 @@
},
{
"id": "terminal",
+ "category": "development",
"term": "terminal",
"match": ["ターミナル", "command line", "コマンドライン"],
"short": "文字でコマンドを入力してコンピュータを操作する画面。",
@@ -403,6 +454,7 @@
},
{
"id": "github",
+ "category": "development",
"term": "GitHub",
"match": ["ギットハブ"],
"short": "Git のリポジトリを保存・共有できるWebサービス。",
@@ -411,6 +463,7 @@
},
{
"id": "git",
+ "category": "development",
"term": "Git",
"match": ["ギット", "version control system", "バージョン管理システム"],
"short": "ファイルの変更履歴を記録・管理するためのツール。",
@@ -420,6 +473,7 @@
},
{
"id": "commit",
+ "category": "development",
"term": "commit",
"match": ["コミット", "git commit"],
"short": "Git でファイルの変更をひとまとまりとして記録すること。",
@@ -429,6 +483,7 @@
},
{
"id": "pull-request",
+ "category": "development",
"term": "pull request",
"match": ["プルリクエスト", "PR", "プルリク"],
"short": "ブランチの変更を取り込み、レビューしてもらうための依頼。",
@@ -437,6 +492,7 @@
},
{
"id": "merge",
+ "category": "development",
"term": "merge",
"match": ["マージ", "git merge"],
"short": "別のブランチにある変更履歴をひとつに統合すること。",
@@ -446,6 +502,7 @@
},
{
"id": "git-pull",
+ "category": "development",
"term": "git pull",
"match": ["pull", "プル"],
"short": "リモートリポジトリの変更を取得し、現在のブランチへ反映する操作。",
@@ -455,6 +512,7 @@
},
{
"id": "git-push",
+ "category": "development",
"term": "git push",
"match": ["push", "プッシュ"],
"short": "ローカルのコミットをリモートリポジトリへ送る操作。",
@@ -464,6 +522,7 @@
},
{
"id": "git-clone",
+ "category": "development",
"term": "git clone",
"match": ["clone", "クローン"],
"short": "リモートリポジトリを履歴ごと手元へ複製する操作。",
@@ -473,6 +532,7 @@
},
{
"id": "git-checkout",
+ "category": "development",
"term": "git checkout",
"match": ["checkout", "チェックアウト"],
"short": "ブランチを切り替えたり、ファイルを指定した状態へ戻したりする操作。",
@@ -482,6 +542,7 @@
},
{
"id": "git-fetch",
+ "category": "development",
"term": "git fetch",
"match": ["fetch", "フェッチ"],
"short": "リモートリポジトリの最新情報を手元へ取得する操作。",
@@ -491,6 +552,7 @@
},
{
"id": "branch",
+ "category": "development",
"term": "branch",
"match": ["ブランチ", "git branch"],
"short": "Git の変更履歴を枝分かれさせて作業する仕組み。",
@@ -500,6 +562,7 @@
},
{
"id": "repository",
+ "category": "development",
"term": "repository",
"match": ["リポジトリ", "repo", "レポジトリ"],
"short": "Git がファイルと変更履歴をまとめて管理する場所。",
@@ -509,6 +572,7 @@
},
{
"id": "git-init",
+ "category": "development",
"term": "git init",
"match": ["init", "イニット", "初期化"],
"short": "現在のディレクトリを新しい Git リポジトリにする操作。",
@@ -518,6 +582,7 @@
},
{
"id": "staging-area",
+ "category": "development",
"term": "staging area",
"match": ["stage", "staging", "ステージ", "ステージング", "index", "インデックス"],
"short": "次のコミットに含める変更を一時的に置く場所。",
@@ -527,6 +592,7 @@
},
{
"id": "html-id",
+ "category": "html-css",
"term": "id attribute",
"match": ["id", "ID", "アイディー", "id属性"],
"short": "HTML 要素にページ内で固有の名前を付ける属性。",
@@ -537,6 +603,7 @@
},
{
"id": "html-tag",
+ "category": "html-css",
"term": "HTML tag",
"match": ["tag", "タグ", "HTMLタグ"],
"short": "HTML で要素の種類や範囲を示すための記号。",
@@ -547,6 +614,7 @@
},
{
"id": "css-margin",
+ "category": "html-css",
"term": "margin",
"match": ["マージン", "外側の余白"],
"short": "CSS で要素の境界線より外側に作る余白。",
@@ -557,6 +625,7 @@
},
{
"id": "css-border",
+ "category": "html-css",
"term": "border",
"match": ["ボーダー", "境界線"],
"short": "CSS で要素の周囲に表示する境界線。",
@@ -567,6 +636,7 @@
},
{
"id": "css-padding",
+ "category": "html-css",
"term": "padding",
"match": ["パディング", "内側の余白"],
"short": "CSS で要素の内容と境界線の間に作る余白。",
@@ -577,6 +647,7 @@
},
{
"id": "css-height",
+ "category": "html-css",
"term": "height",
"match": ["ハイト", "高さ"],
"short": "CSS で要素の高さを指定するプロパティ。",
@@ -587,6 +658,7 @@
},
{
"id": "css-width",
+ "category": "html-css",
"term": "width",
"match": ["ウィズ", "幅"],
"short": "CSS で要素の横幅を指定するプロパティ。",
@@ -597,6 +669,7 @@
},
{
"id": "box-model",
+ "category": "html-css",
"term": "box model",
"match": ["ボックスモデル", "CSS box model"],
"short": "HTML 要素を四角い領域として扱う CSS の考え方。",
@@ -607,6 +680,7 @@
},
{
"id": "responsive-web-design",
+ "category": "html-css",
"term": "responsive web design",
"match": ["レスポンシブウェブデザイン", "responsive design", "レスポンシブデザイン", "RWD"],
"short": "画面の大きさに応じてレイアウトを調整する設計手法。",
@@ -616,6 +690,7 @@
},
{
"id": "npm",
+ "category": "development",
"term": "npm",
"match": ["エヌピーエム"],
"short": "Node.js のパッケージを管理するためのツール。",
@@ -625,6 +700,7 @@
},
{
"id": "ubuntu",
+ "category": "development",
"term": "Ubuntu",
"match": ["ウブンツ"],
"short": "使いやすさを重視して作られた Linux ディストリビューション。",
@@ -634,10 +710,839 @@
},
{
"id": "component",
+ "category": "frameworks",
"term": "component",
"match": ["コンポーネント", "UI component", "UIコンポーネント"],
"short": "画面を構成する再利用可能な部品。",
"detail": "ボタン、入力欄、ヘッダーなどを独立した単位として作り、組み合わせて画面を構築する。見た目や処理をまとめることで保守しやすくなる。",
"example": ""
+ },
+ {
+ "id": "variable",
+ "category": "basics",
+ "term": "variable",
+ "match": [
+ "変数",
+ "バリアブル"
+ ],
+ "short": "値に名前を付けて、プログラムの中で参照するためのもの。",
+ "detail": "言語や宣言方法によって、あとから別の値を代入できるかどうかが異なる。",
+ "example": "let score = 10;"
+ },
+ {
+ "id": "constant",
+ "category": "basics",
+ "term": "constant",
+ "match": [
+ "定数"
+ ],
+ "short": "変更しない値や、再代入できない名前。",
+ "detail": "JavaScriptのconstは再代入を禁止する。オブジェクトや配列の中身まで変更できなくなるわけではない。",
+ "example": "const limit = 100;"
+ },
+ {
+ "id": "function",
+ "category": "basics",
+ "term": "function",
+ "match": [
+ "関数",
+ "ファンクション"
+ ],
+ "short": "ひとまとまりの処理に名前などを付け、繰り返し使えるようにしたもの。",
+ "detail": "引数で値を受け取り、戻り値として結果を返すことができる。",
+ "example": "function double(n) { return n * 2; }"
+ },
+ {
+ "id": "argument",
+ "category": "basics",
+ "term": "argument",
+ "match": [
+ "引数",
+ "アーギュメント"
+ ],
+ "short": "関数を呼び出すときに渡す値。",
+ "detail": "関数を定義するときの受け取り口は仮引数(parameter)、呼び出すときの値は実引数(argument)と区別することもある。",
+ "example": "Math.max(3, 7);"
+ },
+ {
+ "id": "parameter",
+ "category": "basics",
+ "term": "parameter",
+ "match": [
+ "仮引数",
+ "パラメータ",
+ "パラメーター"
+ ],
+ "short": "関数の定義で、呼び出し時の値を受け取るために付ける名前。",
+ "detail": "一般には設定値や処理に与える条件を指す場合もある。",
+ "example": "function greet(name) { return name; }"
+ },
+ {
+ "id": "return-value",
+ "category": "basics",
+ "term": "return value",
+ "match": [
+ "戻り値",
+ "返り値"
+ ],
+ "short": "関数が処理の結果として呼び出し元に返す値。",
+ "detail": "JavaScriptではreturnで指定する。値を返さず終了した関数の戻り値はundefinedになる。",
+ "example": "const result = Math.max(3, 7);"
+ },
+ {
+ "id": "array",
+ "category": "basics",
+ "term": "array",
+ "match": [
+ "配列",
+ "アレイ"
+ ],
+ "short": "複数の値を順番に並べてまとめたもの。",
+ "detail": "各要素を位置を示す番号で取り出す。JavaScriptでは先頭の番号は0。",
+ "example": "const colors = ['red', 'blue'];"
+ },
+ {
+ "id": "string",
+ "category": "basics",
+ "term": "string",
+ "match": [
+ "文字列",
+ "ストリング"
+ ],
+ "short": "文字や記号を並べたデータ。",
+ "detail": "JavaScriptでは引用符などで囲んで書く。数字でも引用符で囲むと数値とは異なる文字列になる。",
+ "example": "const message = 'Hello';"
+ },
+ {
+ "id": "boolean",
+ "category": "basics",
+ "term": "boolean",
+ "match": [
+ "真偽値",
+ "ブーリアン"
+ ],
+ "short": "trueかfalseのどちらかを表すデータ型。",
+ "detail": "条件の成立・不成立や、機能が有効かどうかなどを表す。",
+ "example": "const isReady = true;"
+ },
+ {
+ "id": "conditional",
+ "category": "basics",
+ "term": "conditional statement",
+ "match": [
+ "条件分岐"
+ ],
+ "short": "条件に応じて実行する処理を変えること。",
+ "detail": "ifやswitchなどを使い、入力や状態によって処理を選ぶ。",
+ "example": "if (score >= 60) { console.log('合格'); }"
+ },
+ {
+ "id": "loop",
+ "category": "basics",
+ "term": "loop",
+ "match": [
+ "ループ",
+ "繰り返し処理"
+ ],
+ "short": "同じ処理を繰り返し実行する仕組み。",
+ "detail": "forやwhileなどを使う。終了条件が満たされないと無限ループになることがある。",
+ "example": "for (let i = 0; i < 3; i++) { console.log(i); }"
+ },
+ {
+ "id": "scope",
+ "category": "basics",
+ "term": "scope",
+ "match": [
+ "スコープ",
+ "有効範囲"
+ ],
+ "short": "変数などの名前を参照できる範囲。",
+ "detail": "JavaScriptでは関数やブロックが範囲を作る。letやconstで宣言した名前は、そのブロック内で参照できる。"
+ },
+ {
+ "id": "null",
+ "category": "basics",
+ "term": "null",
+ "match": [
+ "ヌル"
+ ],
+ "short": "値が存在しないことを意図的に表す値。",
+ "detail": "JavaScriptでは、まだ選択されていない項目などを明示するときに使う。undefinedとは別の値。",
+ "example": "const selectedUser = null;"
+ },
+ {
+ "id": "undefined",
+ "category": "basics",
+ "term": "undefined",
+ "match": [
+ "アンディファインド"
+ ],
+ "short": "JavaScriptで値が未設定であることなどを表す値。",
+ "detail": "未代入の変数、存在しないプロパティ、値を返さない関数の戻り値などに現れる。",
+ "example": "let result;"
+ },
+ {
+ "id": "exception",
+ "category": "basics",
+ "term": "exception",
+ "match": [
+ "例外",
+ "エクセプション"
+ ],
+ "short": "通常の処理を続けられない状況を知らせる仕組み。",
+ "detail": "JavaScriptではthrowで発生させ、tryとcatchで処理できる。"
+ },
+ {
+ "id": "recursion",
+ "category": "basics",
+ "term": "recursion",
+ "match": [
+ "再帰",
+ "リカージョン"
+ ],
+ "short": "関数が自分自身を呼び出して問題を解く方法。",
+ "detail": "小さな問題に分けて処理する。呼び出しを止める条件が必要。"
+ },
+ {
+ "id": "operator",
+ "category": "basics",
+ "term": "operator",
+ "match": [
+ "演算子"
+ ],
+ "short": "値の計算や比較などの操作を表す記号。",
+ "detail": "足し算の+、比較の===、論理演算の&&などがある。",
+ "example": "const total = 2 + 3;"
+ },
+ {
+ "id": "data-type",
+ "category": "basics",
+ "term": "data type",
+ "match": [
+ "データ型"
+ ],
+ "short": "値の種類や、その値にできる操作を表す分類。",
+ "detail": "数値、文字列、真偽値などがある。言語によって型の扱いや検査の方法が異なる。"
+ },
+ {
+ "id": "callback",
+ "category": "javascript",
+ "term": "callback",
+ "match": [
+ "コールバック",
+ "コールバック関数"
+ ],
+ "short": "別の処理に渡し、その処理から呼び出してもらう関数。",
+ "detail": "イベント処理や配列操作などに使う。コールバックが必ず非同期で呼ばれるとは限らない。",
+ "example": "[1, 2].map(n => n * 2);"
+ },
+ {
+ "id": "asynchronous",
+ "category": "javascript",
+ "term": "asynchronous",
+ "match": [
+ "非同期処理",
+ "非同期",
+ "async processing"
+ ],
+ "short": "処理の完了を待つ間に、ほかの処理を進められる仕組み。",
+ "detail": "通信やタイマーなどで使う。同時に複数のCPUで実行する並列処理とは同じ意味ではない。"
+ },
+ {
+ "id": "async-await",
+ "category": "javascript",
+ "term": "async/await",
+ "match": [
+ "async await",
+ "アシンクアウェイト"
+ ],
+ "short": "Promiseを使う非同期処理を、順番に読む形で書く構文。",
+ "detail": "async関数はPromiseを返す。awaitはその処理を一時停止して結果を待つが、JavaScript全体を停止するわけではない。",
+ "example": "const response = await fetch('/api/users');"
+ },
+ {
+ "id": "arrow-function",
+ "category": "javascript",
+ "term": "arrow function",
+ "match": [
+ "アロー関数"
+ ],
+ "short": "矢印の記号を使って関数を書く構文。",
+ "detail": "通常の関数とはthisの扱いが異なり、外側のthisを引き継ぐ。",
+ "example": "const double = n => n * 2;"
+ },
+ {
+ "id": "destructuring",
+ "category": "javascript",
+ "term": "destructuring",
+ "match": [
+ "分割代入"
+ ],
+ "short": "配列やオブジェクトから値を取り出し、変数にまとめて代入する構文。",
+ "detail": "必要な要素やプロパティを短く取り出せる。",
+ "example": "const { name, age } = user;"
+ },
+ {
+ "id": "spread-syntax",
+ "category": "javascript",
+ "term": "spread syntax",
+ "match": [
+ "スプレッド構文"
+ ],
+ "short": "配列の要素やオブジェクトのプロパティなどを展開する構文。",
+ "detail": "コピーや結合に使えるが、入れ子のオブジェクトまで複製する深いコピーではない。",
+ "example": "const copy = [...items];"
+ },
+ {
+ "id": "optional-chaining",
+ "category": "javascript",
+ "term": "optional chaining",
+ "match": [
+ "オプショナルチェーン",
+ "オプショナルチェイニング"
+ ],
+ "short": "途中の値がnullやundefinedでもエラーにせずアクセスを止める構文。",
+ "detail": "その場合の結果はundefinedになる。存在するか分からないプロパティを参照するときに使う。",
+ "example": "const name = user?.profile?.name;"
+ },
+ {
+ "id": "type-inference",
+ "category": "javascript",
+ "term": "type inference",
+ "match": [
+ "型推論"
+ ],
+ "short": "明示的に書かれていない型を、値や使い方から推定する仕組み。",
+ "detail": "TypeScriptでは初期値などから型が決まるため、すべての変数に型注釈を書く必要はない。",
+ "example": "const count = 3;"
+ },
+ {
+ "id": "union-type",
+ "category": "javascript",
+ "term": "union type",
+ "match": [
+ "ユニオン型",
+ "共用体型"
+ ],
+ "short": "複数の型のいずれかを受け付ける型。",
+ "detail": "TypeScriptでは縦棒で型を並べる。使うときは型を絞り込む必要がある場合がある。"
+ },
+ {
+ "id": "event-loop",
+ "category": "javascript",
+ "term": "event loop",
+ "match": [
+ "イベントループ"
+ ],
+ "short": "イベントや非同期処理のコールバックを実行する順序を管理する仕組み。",
+ "detail": "JavaScriptの実行中に長い同期処理を行うと、ほかのイベント処理や画面更新が遅れる。"
+ },
+ {
+ "id": "props",
+ "category": "frameworks",
+ "term": "props",
+ "match": [
+ "プロップス"
+ ],
+ "short": "親コンポーネントから子コンポーネントに渡す入力。",
+ "detail": "Reactでは子側で直接書き換えず、渡された値を使って表示や動作を決める。",
+ "example": ""
+ },
+ {
+ "id": "state",
+ "category": "frameworks",
+ "term": "state",
+ "match": [
+ "ステート",
+ "状態"
+ ],
+ "short": "コンポーネントなどが保持する、時間とともに変わるデータ。",
+ "detail": "Reactではstateの更新をきっかけに再描画される。更新用の関数を使って変更する。"
+ },
+ {
+ "id": "hook",
+ "category": "frameworks",
+ "term": "hook",
+ "match": [
+ "フック",
+ "React Hook"
+ ],
+ "short": "Reactの関数コンポーネントなどから状態やライフサイクルに関わる機能を使う仕組み。",
+ "detail": "useStateやuseEffectなどがある。通常のフックは条件分岐やループの中で呼ばず、トップレベルで呼ぶ。"
+ },
+ {
+ "id": "jsx",
+ "category": "frameworks",
+ "term": "JSX",
+ "match": [
+ "ジェイエスエックス"
+ ],
+ "short": "JavaScriptの中にHTMLに似た記法でUIを書ける構文拡張。",
+ "detail": "ビルド時にJavaScriptへ変換される。HTMLそのものではなく、属性などに違いがある。",
+ "example": "const heading = Hello
;"
+ },
+ {
+ "id": "rendering",
+ "category": "frameworks",
+ "term": "rendering",
+ "match": [
+ "レンダリング",
+ "描画"
+ ],
+ "short": "データや構造から表示内容を作る処理。",
+ "detail": "ReactではコンポーネントからUIの構造を計算する。実際のDOM更新やブラウザによる描画は別の段階で行われる。"
+ },
+ {
+ "id": "virtual-dom",
+ "category": "frameworks",
+ "term": "virtual DOM",
+ "match": [
+ "仮想DOM",
+ "バーチャルDOM"
+ ],
+ "short": "画面の構造をJavaScript上のデータとして表したもの。",
+ "detail": "Reactなどは前後の構造を比較し、実際のDOMに反映する変更を決める。"
+ },
+ {
+ "id": "routing",
+ "category": "frameworks",
+ "term": "routing",
+ "match": [
+ "ルーティング"
+ ],
+ "short": "URLなどに応じて表示する画面や実行する処理を選ぶ仕組み。",
+ "detail": "ブラウザ内で画面を切り替える場合と、サーバーでリクエストの処理先を選ぶ場合がある。"
+ },
+ {
+ "id": "hydration",
+ "category": "frameworks",
+ "term": "hydration",
+ "match": [
+ "ハイドレーション"
+ ],
+ "short": "サーバーが生成したHTMLに、ブラウザ側の動作を結び付ける処理。",
+ "detail": "表示済みの画面を操作可能にする。サーバーとブラウザで内容が食い違うと問題になることがある。"
+ },
+ {
+ "id": "dom",
+ "category": "html-css",
+ "term": "DOM",
+ "match": [
+ "ドム",
+ "Document Object Model"
+ ],
+ "short": "HTMLなどの文書を、プログラムから操作できる木構造として表したもの。",
+ "detail": "JavaScriptから要素やテキストを取得・追加・変更できる。",
+ "example": "document.querySelector('h1');"
+ },
+ {
+ "id": "event",
+ "category": "html-css",
+ "term": "event",
+ "match": [
+ "イベント"
+ ],
+ "short": "クリックや入力など、プログラムが反応できる出来事。",
+ "detail": "要素にリスナーを登録して、出来事が発生したときに処理を実行する。",
+ "example": "button.addEventListener('click', handleClick);"
+ },
+ {
+ "id": "event-listener",
+ "category": "html-css",
+ "term": "event listener",
+ "match": [
+ "イベントリスナー",
+ "イベントリスナ"
+ ],
+ "short": "イベントが発生したときに呼び出される処理。",
+ "detail": "addEventListenerで登録できる。不要になったリスナーを解除すると、不要な処理や参照を減らせる。"
+ },
+ {
+ "id": "css-selector",
+ "category": "html-css",
+ "term": "CSS selector",
+ "match": [
+ "selector",
+ "セレクタ",
+ "セレクター"
+ ],
+ "short": "スタイルを適用するHTML要素などを選ぶための指定。",
+ "detail": "要素名、クラス、ID、親子関係などで対象を絞れる。",
+ "example": ".card > p { color: blue; }"
+ },
+ {
+ "id": "flexbox",
+ "category": "html-css",
+ "term": "Flexbox",
+ "match": [
+ "flex box",
+ "フレックスボックス"
+ ],
+ "short": "要素を主に横一列または縦一列に並べ、配置や幅を調整する仕組み。",
+ "detail": "display: flexを指定し、折り返しや間隔、中央揃えなどを設定する。",
+ "example": ".row { display: flex; gap: 8px; }"
+ },
+ {
+ "id": "css-grid",
+ "category": "html-css",
+ "term": "CSS Grid",
+ "match": [
+ "grid layout",
+ "グリッドレイアウト"
+ ],
+ "short": "行と列を使って要素を配置するCSSの仕組み。",
+ "detail": "二次元のレイアウトに向いており、列幅や行の高さをまとめて指定できる。",
+ "example": ".grid { display: grid; grid-template-columns: 1fr 1fr; }"
+ },
+ {
+ "id": "media-query",
+ "category": "html-css",
+ "term": "media query",
+ "match": [
+ "メディアクエリ"
+ ],
+ "short": "画面幅などの条件に応じてCSSを切り替える仕組み。",
+ "detail": "小さい画面向けのレイアウトや、ダークモード向けの配色などに使う。",
+ "example": "@media (max-width: 600px) { .menu { display: none; } }"
+ },
+ {
+ "id": "accessibility",
+ "category": "html-css",
+ "term": "accessibility",
+ "match": [
+ "アクセシビリティ"
+ ],
+ "short": "さまざまな状況や特性を持つ人が情報や機能を利用できること。",
+ "detail": "キーボード操作、読み上げへの対応、十分な色のコントラストなどが関係する。"
+ },
+ {
+ "id": "build",
+ "category": "development",
+ "term": "build",
+ "match": [
+ "ビルド"
+ ],
+ "short": "ソースコードから実行・配布できるファイルを作る処理。",
+ "detail": "型チェック、コード変換、ファイルの結合やコピーなどが含まれることがある。",
+ "example": "npm run build"
+ },
+ {
+ "id": "bundle",
+ "category": "development",
+ "term": "bundle",
+ "match": [
+ "バンドル",
+ "バンドリング"
+ ],
+ "short": "複数のコードや依存ファイルをまとめること。",
+ "detail": "ブラウザ向けのJavaScriptなどを配布しやすくする。用途に応じて複数の出力ファイルに分ける場合もある。"
+ },
+ {
+ "id": "deploy",
+ "category": "development",
+ "term": "deploy",
+ "match": [
+ "デプロイ",
+ "デプロイメント"
+ ],
+ "short": "作ったアプリを利用できる環境へ配置・反映すること。",
+ "detail": "サーバーへの配置やホスティングサービスへの公開などを指す。"
+ },
+ {
+ "id": "dependency",
+ "category": "development",
+ "term": "dependency",
+ "match": [
+ "依存関係",
+ "依存パッケージ"
+ ],
+ "short": "あるコードやツールを動かすために必要な別の部品。",
+ "detail": "外部ライブラリなどが該当する。バージョンや更新による互換性に注意する。"
+ },
+ {
+ "id": "environment-variable",
+ "category": "development",
+ "term": "environment variable",
+ "match": [
+ "環境変数"
+ ],
+ "short": "実行環境からプログラムへ渡す、名前付きの設定値。",
+ "detail": "接続先や実行モードなどを切り替える。ブラウザ向けコードに埋め込んだ値は秘密にはできない。",
+ "example": "process.env.NODE_ENV"
+ },
+ {
+ "id": "package",
+ "category": "development",
+ "term": "package",
+ "match": [
+ "パッケージ"
+ ],
+ "short": "再利用・配布できる単位にまとめたコードや関連ファイル。",
+ "detail": "npmではpackage.jsonに名前や依存関係などを記録する。"
+ },
+ {
+ "id": "module",
+ "category": "development",
+ "term": "module",
+ "match": [
+ "モジュール"
+ ],
+ "short": "機能をひとまとまりに分けたコードの単位。",
+ "detail": "JavaScriptではexportで公開し、importで別のファイルから利用できる。",
+ "example": "import { helper } from './helper.js';"
+ },
+ {
+ "id": "debugging",
+ "category": "development",
+ "term": "debugging",
+ "match": [
+ "debug",
+ "デバッグ"
+ ],
+ "short": "不具合の原因を調べ、動作を確認・修正する作業。",
+ "detail": "ログやブレークポイントを使い、値や処理の流れを追いかける。"
+ },
+ {
+ "id": "unit-test",
+ "category": "development",
+ "term": "unit test",
+ "match": [
+ "単体テスト",
+ "ユニットテスト"
+ ],
+ "short": "関数などの小さな単位が期待どおり動くか確かめるテスト。",
+ "detail": "入力に対する結果や、エラー時の動作などを自動で検証する。"
+ },
+ {
+ "id": "continuous-integration",
+ "category": "development",
+ "term": "continuous integration",
+ "match": [
+ "CI",
+ "継続的インテグレーション"
+ ],
+ "short": "変更を取り込む際にビルドやテストなどを自動実行する開発の仕組み。",
+ "detail": "GitHub Actionsなどで実行し、不具合や統合時の問題を早く見つける。"
+ },
+ {
+ "id": "http",
+ "category": "network-data",
+ "term": "HTTP",
+ "match": [
+ "Hypertext Transfer Protocol"
+ ],
+ "short": "Webでリクエストとレスポンスをやり取りするための通信規則。",
+ "detail": "URLで対象を指定し、メソッドやヘッダー、本文などを使って通信する。"
+ },
+ {
+ "id": "https",
+ "category": "network-data",
+ "term": "HTTPS",
+ "match": [
+ "エイチティーティーピーエス"
+ ],
+ "short": "TLSによって通信を保護したHTTP。",
+ "detail": "通信内容の暗号化、改ざんの検知、接続先の認証に使われる。サイトの内容そのものが安全だと保証するものではない。"
+ },
+ {
+ "id": "request",
+ "category": "network-data",
+ "term": "request",
+ "match": [
+ "リクエスト"
+ ],
+ "short": "クライアントなどから相手に送る、処理やデータを求める要求。",
+ "detail": "HTTPではメソッド、URL、ヘッダー、必要に応じた本文などから構成される。"
+ },
+ {
+ "id": "response",
+ "category": "network-data",
+ "term": "response",
+ "match": [
+ "レスポンス"
+ ],
+ "short": "リクエストを受け取った相手が返す応答。",
+ "detail": "HTTPではステータスコード、ヘッダー、必要に応じた本文などを含む。"
+ },
+ {
+ "id": "json",
+ "category": "network-data",
+ "term": "JSON",
+ "match": [
+ "ジェイソン",
+ "JavaScript Object Notation"
+ ],
+ "short": "オブジェクトや配列などのデータを文字列で表す形式。",
+ "detail": "API通信や設定ファイルで使う。キーや文字列は二重引用符で囲み、コメントは書けない。",
+ "example": "{\"name\": \"Aki\", \"active\": true}"
+ },
+ {
+ "id": "url",
+ "category": "network-data",
+ "term": "URL",
+ "match": [
+ "ユーアールエル",
+ "Uniform Resource Locator"
+ ],
+ "short": "Web上などにある情報の場所やアクセス方法を表す文字列。",
+ "detail": "httpsなどのスキーム、ホスト名、パス、クエリなどから構成される。",
+ "example": "https://example.com/users?page=2"
+ },
+ {
+ "id": "cookie",
+ "category": "network-data",
+ "term": "Cookie",
+ "match": [
+ "クッキー"
+ ],
+ "short": "ブラウザが保存し、条件に合うリクエストでサーバーに送る小さなデータ。",
+ "detail": "ログイン状態の識別や設定の保持などに使う。送信先や有効期限などを指定できる。"
+ },
+ {
+ "id": "session",
+ "category": "network-data",
+ "term": "session",
+ "match": [
+ "セッション"
+ ],
+ "short": "一連のアクセスを同じ利用者のやり取りとして扱う単位や仕組み。",
+ "detail": "Webでは識別子をCookieなどで受け渡し、サーバー側の状態と対応付ける方法がある。"
+ },
+ {
+ "id": "database",
+ "category": "network-data",
+ "term": "database",
+ "match": [
+ "データベース",
+ "DB"
+ ],
+ "short": "データを整理して保存し、検索や更新を行うための仕組み。",
+ "detail": "表形式で扱うリレーショナルデータベースなど、用途に応じた種類がある。"
+ },
+ {
+ "id": "sql",
+ "category": "network-data",
+ "term": "SQL",
+ "match": [
+ "エスキューエル",
+ "Structured Query Language"
+ ],
+ "short": "主にリレーショナルデータベースのデータを操作するための言語。",
+ "detail": "SELECTで検索し、INSERTで追加、UPDATEで更新、DELETEで削除する。",
+ "example": "SELECT name FROM users;"
+ },
+ {
+ "id": "query",
+ "category": "network-data",
+ "term": "query",
+ "match": [
+ "クエリ"
+ ],
+ "short": "データベースなどに送る、検索や操作の要求。",
+ "detail": "SQL文を指す場合や、URLに付ける検索条件などを指す場合がある。"
+ },
+ {
+ "id": "transaction",
+ "category": "network-data",
+ "term": "transaction",
+ "match": [
+ "トランザクション"
+ ],
+ "short": "複数の操作をひとまとまりとして扱う処理単位。",
+ "detail": "データベースでは途中で失敗した際に取り消すなどして、データの整合性を保つ。"
+ },
+ {
+ "id": "authentication",
+ "category": "network-data",
+ "term": "authentication",
+ "match": [
+ "認証"
+ ],
+ "short": "アクセスしている相手が誰かを確かめること。",
+ "detail": "パスワードや認証器などを使う。何をしてよいか決める認可とは区別する。"
+ },
+ {
+ "id": "authorization",
+ "category": "network-data",
+ "term": "authorization",
+ "match": [
+ "認可"
+ ],
+ "short": "利用者などが特定の操作をしてよいか判断すること。",
+ "detail": "役割や権限に応じて、閲覧・編集などの操作を許可する。"
+ },
+ {
+ "id": "algorithm",
+ "category": "design-performance",
+ "term": "algorithm",
+ "match": [
+ "アルゴリズム"
+ ],
+ "short": "問題を解くための手順。",
+ "detail": "並べ替えや検索などにさまざまな方法があり、入力の大きさによって処理時間やメモリ使用量が変わる。"
+ },
+ {
+ "id": "data-structure",
+ "category": "design-performance",
+ "term": "data structure",
+ "match": [
+ "データ構造"
+ ],
+ "short": "データを保存・整理する方法。",
+ "detail": "配列、リスト、木、ハッシュ表などがあり、探し方や更新の仕方に応じて使い分ける。"
+ },
+ {
+ "id": "time-complexity",
+ "category": "design-performance",
+ "term": "time complexity",
+ "match": [
+ "時間計算量",
+ "計算量"
+ ],
+ "short": "入力の大きさに対して処理の手間がどう増えるかを表す尺度。",
+ "detail": "O(n)やO(n²)などで表す。実際の秒数そのものを表すわけではない。"
+ },
+ {
+ "id": "memory-leak",
+ "category": "design-performance",
+ "term": "memory leak",
+ "match": [
+ "メモリリーク"
+ ],
+ "short": "不要になったデータなどが解放されず、メモリを使い続ける問題。",
+ "detail": "JavaScriptでも、不要なイベントリスナーや参照を残すことで発生する。"
+ },
+ {
+ "id": "lazy-loading",
+ "category": "design-performance",
+ "term": "lazy loading",
+ "match": [
+ "遅延読み込み",
+ "遅延ロード"
+ ],
+ "short": "必要になるまでデータやコードなどの読み込みを後回しにする方法。",
+ "detail": "最初の読み込み量を減らせる一方、使う瞬間に待ち時間が生じる場合がある。"
+ },
+ {
+ "id": "pagination",
+ "category": "design-performance",
+ "term": "pagination",
+ "match": [
+ "ページネーション",
+ "ページ分割"
+ ],
+ "short": "大量のデータを一定件数ずつに分けて取得・表示する方法。",
+ "detail": "一覧全体を一度に読み込まずに済むため、通信量や描画の負荷を抑えられる。"
+ },
+ {
+ "id": "rate-limit",
+ "category": "design-performance",
+ "term": "rate limit",
+ "match": [
+ "レート制限",
+ "レートリミット"
+ ],
+ "short": "一定時間に受け付けるリクエストなどの回数を制限する仕組み。",
+ "detail": "過負荷や乱用を防ぐために使う。制限された側は待機や再試行の間隔を考慮する。"
}
]
diff --git a/packages/core/src/dictionary.test.ts b/packages/core/src/dictionary.test.ts
index dad4796..52eaee5 100644
--- a/packages/core/src/dictionary.test.ts
+++ b/packages/core/src/dictionary.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { Dictionary, builtinDictionary, normalize } from './index';
+import { Dictionary, builtinDictionary, builtinTerms, termCategories, normalize } from './index';
test('normalize が表記ゆれを吸収する', () => {
assert.equal(normalize('useState'), 'use state');
@@ -49,3 +49,37 @@ test('lang 指定があるとその言語向けのエントリが優先される
test('未登録の語は undefined', () => {
assert.equal(builtinDictionary.lookup('foobarbaz'), undefined);
});
+
+test('同梱用語はすべて既知のカテゴリに属し、IDは一意', () => {
+ const categories = new Set(termCategories.map(({ id }) => id));
+ assert.equal(new Set(builtinTerms.map(({ id }) => id)).size, builtinTerms.length);
+ for (const entry of builtinTerms) assert.ok(entry.category && categories.has(entry.category), entry.id);
+});
+
+test('追加した基本語・Web用語を英語や略語で検索できる', () => {
+ for (const [surface, id] of [['variable', 'variable'], ['async/await', 'async-await'], ['props', 'props'], ['CSS Grid', 'css-grid'], ['CI', 'continuous-integration'], ['JSON', 'json'], ['rate limit', 'rate-limit']]) {
+ assert.equal(builtinDictionary.lookup(surface)?.id, id);
+ const text = `Use ${surface} here`;
+ assert.ok(builtinDictionary.findAll(text).some(hit => hit.entry.id === id), surface);
+ }
+});
+
+test('日本語の文章中から用語を拾い、ハイライトとホバーの範囲が一致する', () => {
+ const text = '😀変数に配列を代入し、関数の戻り値を確認する。';
+ const matches = builtinDictionary.findAll(text);
+ assert.deepEqual(matches.map(hit => text.slice(hit.start, hit.end)), ['変数', '配列', '関数', '戻り値']);
+ for (const hit of matches) {
+ for (let i = hit.start; i < hit.end; i++) assert.deepEqual(builtinDictionary.findAt(text, i), hit);
+ }
+});
+
+test('日本語の複合語を優先し、カテゴリで除外した語や一般的な一文字を拾わない', () => {
+ const text = '仮引数と引数、仮想DOMとDOM、コールバック関数';
+ const matches = builtinDictionary.findAll(text);
+ assert.deepEqual(matches.map(hit => hit.entry.id), ['parameter', 'argument', 'virtual-dom', 'dom', 'callback']);
+ assert.equal(builtinDictionary.findAt(text, text.indexOf('DOM'))?.entry.id, 'virtual-dom');
+ const filtered = new Dictionary(builtinTerms.filter(entry => entry.category !== 'basics'));
+ assert.equal(filtered.findAll('変数と配列と関数').length, 0);
+ assert.equal(filtered.findAt('変数', 0), undefined);
+ assert.equal(builtinDictionary.findAll('値と幅').length, 0);
+});
diff --git a/packages/core/src/dictionary.ts b/packages/core/src/dictionary.ts
index e7c3851..adaa9ff 100644
--- a/packages/core/src/dictionary.ts
+++ b/packages/core/src/dictionary.ts
@@ -5,9 +5,16 @@ import type { LookupOptions, Match, TermEntry } from './types';
const MAX_PHRASE_TOKENS = 4;
/** トークンとトークンの間に許す文字(ここ以外が挟まれば別の語とみなす)。 */
-const JOINER_RE = /^[\s\-_.]*$/;
+const JOINER_RE = /^[\s\-_./]*$/;
+
+interface JapaneseNode {
+ children: Map;
+ surface?: string;
+}
export class Dictionary {
+ private readonly japanese: JapaneseNode = { children: new Map() };
+
/** 正規化キー -> エントリ群(同綴りで言語違いがありうるので配列)。 */
private readonly index = new Map();
@@ -17,6 +24,17 @@ export class Dictionary {
const norm = normalize(surface);
if (!norm) continue;
this.add(norm, entry);
+ // 漢字・ひらがなを含む登録済み表記だけを直接照合する。
+ // 「値」「幅」など一文字の一般語は文章中で過剰に検出しない。
+ if (surface.length > 1 && /[\p{Script=Han}\p{Script=Hiragana}]/u.test(surface)) {
+ let node = this.japanese;
+ for (let i = 0; i < surface.length; i++) {
+ const char = surface[i];
+ if (!node.children.has(char)) node.children.set(char, { children: new Map() });
+ node = node.children.get(char)!;
+ }
+ node.surface = surface;
+ }
const tight = tighten(norm);
if (tight !== norm) this.add(tight, entry);
}
@@ -54,6 +72,14 @@ export class Dictionary {
* VS Code のホバーもブラウザのホバーもこの 1 関数に集約する。
*/
findAt(text: string, offset: number, options: LookupOptions = {}): Match | undefined {
+ const tokenMatch = this.findTokenAt(text, offset, options);
+ const japaneseMatches = this.findJapanese(text, options)
+ .filter(({ start, end }) => offset >= start && offset < end);
+ if (tokenMatch) japaneseMatches.push(tokenMatch);
+ return japaneseMatches.sort((a, b) => (b.end - b.start) - (a.end - a.start))[0];
+ }
+
+ private findTokenAt(text: string, offset: number, options: LookupOptions): Match | undefined {
const tokens = tokenize(text);
const hit = tokens.findIndex((t) => offset >= t.start && offset <= t.end);
if (hit === -1) return undefined;
@@ -93,6 +119,34 @@ export class Dictionary {
}
}
}
+ const combined = [...matches, ...this.findJapanese(text, options)]
+ .sort((a, b) => a.start - b.start || b.end - a.end);
+ const result: Match[] = [];
+ for (const match of combined) {
+ if (!result.length || match.start >= result[result.length - 1].end) result.push(match);
+ }
+ return result;
+ }
+
+ private findJapanese(text: string, options: LookupOptions): Match[] {
+ const matches: Match[] = [];
+ for (let start = 0; start < text.length; start++) {
+ let node = this.japanese;
+ let longest: Match | undefined;
+ for (let end = start; end < text.length; end++) {
+ const next = node.children.get(text[end]);
+ if (!next) break;
+ node = next;
+ if (node.surface) {
+ const entry = this.lookup(node.surface, options);
+ if (entry) longest = { entry, start, end: end + 1 };
+ }
+ }
+ if (longest) {
+ matches.push(longest);
+ start = longest.end - 1;
+ }
+ }
return matches;
}
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 7086892..bf974ed 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -12,3 +12,6 @@ export const builtinTerms: readonly TermEntry[] = termsData as TermEntry[];
/** 同梱辞書だけで作った既定の Dictionary。 */
export const builtinDictionary = new Dictionary(builtinTerms);
+
+export { termCategories } from './categories';
+export type { TermCategory } from './categories';
diff --git a/packages/core/src/normalize.ts b/packages/core/src/normalize.ts
index fd6fce4..2bba0b9 100644
--- a/packages/core/src/normalize.ts
+++ b/packages/core/src/normalize.ts
@@ -31,8 +31,7 @@ export function tighten(normalized: string): string {
/**
* 語トークンの抽出。ASCII の識別子と、カタカナの連続を 1 トークンとして扱う。
- * 漢字・ひらがなは形態素解析なしでは切れないので、辞書側にカタカナ/英字の
- * 別名を書いてもらう前提であえて対象外にしている。
+ * 漢字・ひらがなを含む表記はDictionary側で登録済みの語を直接照合する。
*/
const TOKEN_RE = /[A-Za-z_$][A-Za-z0-9_$]*|[ァ-ヶーヲ-゚]+/gu;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index f10ef23..b03f0b8 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,7 +1,11 @@
+import type { TermCategory } from './categories';
+
/** 辞書 1 件分のエントリ。data/terms.json がこの配列。 */
export interface TermEntry {
/** 安定した内部 ID。ファイル内で一意にする。 */
id: string;
+ /** 表示カテゴリ。省略したユーザー辞書はプログラミング基礎として扱う。 */
+ category?: TermCategory;
/** 見出しとして表示する語。 */
term: string;
/**