🐧

Penguin Language Plus

🗂 コードツアー

このページはPenguin Language Plusのソースコードを解説しています。
サイトの中にサイトの説明が入る — マトリョーシカ設計。

技術スタック

フレームワークは最小限。Astroのテンプレートエンジン+バニラJSのみで構築しています。

AstroVanilla JSlocalStorageWeb Speech APICSS Custom PropertiesVercel deploy
ビルド: npm run build → Vercel に自動デプロイ(git push するだけ)。
ページは全て静的HTML。バックエンドなし、DBなし、オフラインでも動く。

⌨️ ローマ字変換エンジン

toRomaji() がひらがな・カタカナをローマ字に変換します。 拗音(きゃ→kya など2文字)を先に照合してから1文字照合する2段階ルックアップです。

// 変換テーブル(拗音を先に定義することが重要)
const KR = {
  'きゃ':'kya', 'しゃ':'sha', 'ちゃ':'cha', // 拗音(2文字)
  'あ':'a',   'い':'i',   'う':'u',   // 単文字
  'っ':/* 次の子音を重ねる特殊処理 */
};

function hNorm(c) {  // カタカナ→ひらがな正規化
  const code = c.charCodeAt(0);
  return (code >= 0x30A1 && code <= 0x30F3)
    ? String.fromCharCode(code - 0x60) : c;
}

function toRomaji(text) {
  let res='', i=0;
  while (i < text.length) {
    const c = hNorm(text[i]); // カタカナ→ひらがな正規化
    // 拗音(2文字)を先にチェック
    if (i+1 < text.length) {
      const two = c + hNorm(text[i+1]);
      if (KR[two]) { res += KR[two]; i+=2; continue; }
    }
    res += KR[c] || c; i++;  // 単文字、またはそのまま
  }
  return res;
}

🎯 タイピング判定とオーバーレイ

透明な <input> をガイド表示 .target-display の上に重ね、 1文字ずつ tc-ok / tc-ng / tc-cur / tc-rest クラスで色分けします。

/* CSS: 透明inputをdisplayの上に絶対配置 */
.type-area { position: relative; }
.type-input {
  position: absolute; inset: 0;
  background: transparent; color: transparent;
  caret-color: var(--accent);  /* カーソルだけ見える */
  border: none; outline: none;
}

// JS: 1文字ずつクラスで色分け
function updateTypeDisplay(input) {
  const target = getTypeTarget(pairs[typeIdx]);
  let html = '';
  for (let i=0; i < target.length; i++) {
    const ch = target[i];
    if      (i < input.length)  html += input[i]===ch
      ? '<span class="tc-ok">'+ch+'</span>'   // 緑: 正解
      : '<span class="tc-ng">'+ch+'</span>';  // 赤: 誤り
    else if (i===input.length)  html += '<span class="tc-cur">'+ch+'</span>';
    else                        html += '<span class="tc-rest">'+ch+'</span>';
  }
  document.getElementById('type-display').innerHTML = html;
}

🗂 タブシステム

setupTabs(groupId) は汎用タブ切替関数です。 グループIDを渡すとその中の .tab ボタンを全て監視し、クリック時に対応する #panel-{名前} を表示します。 2グループ(日本語・自分の練習)に同じ関数を使い回しています。

function setupTabs(groupId) {
  document.querySelectorAll(`#${groupId} .tab`).forEach(btn => {
    btn.addEventListener('click', () => {
      // 全タブ・全パネルを非アクティブに
      document.querySelectorAll('.tab'  ).forEach(b => b.classList.remove('active'));
      document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
      // クリックされたタブとパネルをアクティブに
      btn.classList.add('active');
      document.getElementById('panel-' + btn.dataset.tab).classList.add('active');
    });
  });
}
setupTabs('tabs-lang');     // 日本語グループ(文法マップ・発音・単語集…)
setupTabs('tabs-practice'); // 練習グループ(例文・学習・タイピング…)

💾 データ保存(localStorage)

ユーザーの例文はブラウザの localStorage にJSONで保存します。 サーバー不要、オフライン動作、ページリロードしてもデータが残ります。

const STORAGE_KEY = 'pl_pairs_v2';

function save() {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(pairs)); }
  catch(e) {}  // プライベートモードでも落ちないよう try/catch
}
function load() {
  try {
    const d = localStorage.getItem(STORAGE_KEY);
    if (d) pairs = JSON.parse(d);
  } catch(e) {}
}
このページ自体がドキュメントであり、ドキュメントがこのページである — マトリョーシカ設計。