「発想がイかれてる」と言われたコーポレートサイトを作った話
はじめに
弊社のコーポレートサイトは WordPress で運用していた。よくある企業サイトだ。
ある日、思った。
「エンジニアが会社のHPを見に来たとき、普通のコーポレートサイトで本当にいいのか?」
我々 (私と猫) はシステム開発会社だ。来訪者の多くはエンジニアか、エンジニアと仕事をする人たちだ。であれば、サイト自体が「この会社、面白いな」と思わせるものであるべきではないか。
そうして生まれたのが、同一 URL で GUI / CLI / Designer の3モードが切り替わるハイブリッド型コーポレートサイトだ。
何を作ったのか
モード1: GUI(一般向け)
普通のモダンなコーポレートサイト。何も特別なことはない。
モード2: CLI(エンジニア向け)
サイト全体がターミナルエミュレータになる。 ls でページ一覧、cd works でセクション移動、cat project-alpha でコンテンツ表示。URL バーも同期する。
モード3: Designer(デザイナー向け)
CodeMirror 6 のデュアルエディタが開き、サイトの CSS と HTML をリアルタイムに書き換えられる。保存すると共有URLが発行され、他の人にも見せられる。
初回訪問時に「あなたの専門は?」と聞いて、エンジニアなら CLI、デザイナーなら Designer、それ以外なら GUI をデフォルトにする。ヘッダーのセグメントボタンでいつでも切り替え可能。
Bot(Googlebot 等)には常に GUI の HTML を返すので SEO も問題ない。
技術スタック
| レイヤー | 技術 |
|---|---|
| Backend | Laravel 12 |
| Reactive UI | Livewire 4 + Alpine.js |
| CSS | Tailwind CSS v4 |
| Build | Vite 7 |
| DB | MySQL 8.4 (開発環境) / MariaDB 10.5 (本番環境) |
| Container | Laravel Sail |
| Fonts | JetBrains Mono (セルフホスト) + Inter |
WordPress → Laravel への移行なので、既存コンテンツの移行よりもアーキテクチャの設計が肝だった。
アーキテクチャ: 全ページを1つの Livewire コンポーネントで受ける
Request
→ DisplayModeMiddleware(Bot検出 + モード判定)
→ HybridPage(Livewire フルページコンポーネント)
├── GUI → GuiContent コンポーネント
└── CLI → CliTerminal コンポーネント
ルーティングは驚くほどシンプル。
// routes/web.php
Route::get('/', HybridPage::class)->name('home');
Route::get('/{section}', HybridPage::class)->name('section');
Route::get('/{section}/{slug}', HybridPage::class)->name('content');
全ページを HybridPage という単一の Livewire フルページコンポーネントで受け、内部で GUI/CLI を出し分ける。SPA のルーターに近い発想だが、SSR なので初回表示にコンテンツが含まれる。
モード判定の優先順位
// DisplayModeMiddleware.php
// 1. Bot → 強制 GUI(SEO担保)
// 2. ?mode=cli クエリパラメータ → 即座に切替
// 3. Session
// 4. Cookie(30日保持)
// 5. デフォルト: gui
Bot 判定は User-Agent のパターンマッチング。Googlebot, Bingbot 含む14種を検出する。
private const BOT_PATTERNS = [
'googlebot', 'bingbot', 'slurp', 'duckduckbot',
'baiduspider', 'yandexbot', 'facebot', 'ia_archiver',
'twitterbot', 'linkedinbot', 'embedly', 'showyoubot',
'outbrain', 'pinterest', 'quora link preview',
];
CLI モード: Livewire でターミナルエミュレータを作る
ここが一番楽しかったパートかつLivewireに慣れてないので苦労した部分。
実装したコマンド
本物のターミナルさながらのコマンドが使える。
$ help
Available commands:
ls [-la] List contents of current section
ll Alias for ls -l (detailed listing)
cd <dir> Navigate to a section (cd works, cd .., cd ~)
cat <file> Display content of a page
pwd Print current path
tree Display site structure
whoami Display visitor info
clear Clear terminal
history Show command history
gui Switch to GUI mode
help Show this help message
ls -l の遊び心
$ ls -l
total 5
-rw-r--r-- 1 sai staff 2847 2024-06-15 company
-rw-r--r-- 1 sai staff 1523 2024-06-15 philosophy
-rw-r--r-- 1 sai staff 892 2024-07-01 team
ファイルサイズ欄には コンテンツの文字数、日時欄には実際の公開日を表示している。パーミッションは -rw-r--r-- 固定だが、雰囲気は十分だ。
URL 同期の仕組み
CLI で cd works すると URL バーが /works に変わる。逆に、GUI で /services を見ている状態で CLI に切り替えると、自動で ls が実行される。
// CliTerminal.php — コンテキスト引き継ぎ
public function mount(?string $initialSection = null, ?string $initialSlug = null): void
{
if ($initialSection) {
$this->currentPath = '/' . $initialSection;
if ($initialSlug) {
$this->executeCommand("cat {$initialSlug}");
} else {
$this->executeCommand('ls');
}
}
}
URL 同期は Livewire のイベントと Alpine.js の連携で実現している。
// CommandParser.php — cd コマンドの戻り値
return [
'output' => '',
'newPath' => '/' . $section->slug,
'syncUrl' => '/' . $section->slug, // ← これがポイント
];
// Alpine.js 側
x-on:url-sync.window="
if (mode === 'cli') {
window.history.pushState({}, '', $event.detail.path);
}
$wire.navigateTo($event.detail.path);
"
Tab 補完
本物のターミナルのように Tab キーで補完できる。DB からセクションやコンテンツのスラグを取得し、共通プレフィックスまで自動補完する。
// セクション名にスラッシュを付けて返す(ディレクトリっぽく)
->map(fn($slug) => $slug . '/')
->toArray();
25種類のイースターエッグ
エンジニアなら絶対に打つであろうコマンドを仕込んだ。
例えば cowsay を会社のロゴであるうさぎバージョンで実装したり。。。
╭──────╮
│ moo │
╰──────╯
\ (\__/)
\ (•ㅅ•)
/ づ
ぜひ見つけてみてください!
CRT トランジション: モード切替を「体験」にする
GUI → CLI の切り替えで、ただパッと画面が変わるのでは面白くない。古いCRTモニターの電源ON/OFFを再現した。
GUI → CLI(電源ON)
3フェーズで構成される。
/* Phase 1: フリッカー(画面がチカチカ) */
@keyframes crt-flicker {
0% { opacity: 0; }
5% { opacity: 0.8; }
10% { opacity: 0.2; }
15% { opacity: 0.9; }
20% { opacity: 0.4; }
30% { opacity: 1; }
100% { opacity: 1; }
}
/* Phase 2: 緑のPhosphorグロー + ノイズ */
.crt-glow {
background: radial-gradient(
ellipse at center,
rgba(0, 255, 65, 0.15) 0%,
rgba(0, 255, 65, 0.05) 40%,
transparent 70%
);
}
/* Phase 3: フェードアウト → CLI表示 */
CLI → GUI(電源OFF)
CRT の「画面が中心の線に収束して消える」あの演出。
@keyframes crt-turn-off {
0% { transform: scale(1, 1); filter: brightness(1); }
30% { transform: scale(1, 1); filter: brightness(5); }
50% { transform: scale(1.1, 0.005); filter: brightness(20); }
100% { opacity: 0; transform: scale(0, 0); filter: brightness(0); }
}
scale(1.1, 0.005) がミソだ。横に少し広がりながら縦が潰れて1本の線になる。実際のブラウン管テレビの消え方を観察して数値を決めた。
CLI モード中の常時エフェクト
CLI 表示中は薄い緑のスキャンラインが常に走っている。
.cli-mode-active::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent, transparent 2px,
rgba(0, 255, 0, 0.015) 2px,
rgba(0, 255, 0, 0.015) 4px
);
}
opacity: 0.015 という絶妙な薄さ。目を凝らすと見えるレベルだが、ターミナルの雰囲気を確実に底上げしている。
地獄のスクロール問題: Livewire と DOM の狭間で
ここからが本当の戦いだった。
「コマンドを実行したら、出力の末尾まで自動スクロールする」——ターミナルとしては当然の機能だ。しかし Livewire でこれを実現するのに 8パターンの試行錯誤 を要した。
なぜ難しいのか
Livewire 4 はサーバーで状態を更新した後、差分を DOM に morph する。この morph が完了するタイミングを正確に捕捉する手段が限られている。
試行1: Livewire.hook('morph.updated') → NG
コマンド実行直後に発火しない。次のキー操作時に初めて発火する。
試行2: requestAnimationFrame 2段階 → NG
DOM morph 自体が完了していないため rAF でも間に合わない。
試行3: setTimeout 多段階 (0ms, 50ms, 150ms) → NG
morph.updated が発火しないので遅延量の問題ではなかった。
試行4: MutationObserver → NG
Livewire の morph と噛み合わない。
試行5: $watch('$wire.history') → NG
$wire は Livewire のプロキシで、Alpine の $watch が変更を検知できない。
試行6: window.addEventListener → NG
ここで重要な発見があった。 Livewire の $this->dispatch() はネイティブ DOM イベントではない。window.addEventListener ではキャッチできない。
試行7: Livewire.hook('commit', {succeed}) → NG
Alpine のコンテキストとグローバルスコープの関係で動かず。
最終解決: Livewire.on() + requestAnimationFrame 3重ネスト
Livewire.on('terminal-scroll-bottom', () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.scrollToBottom();
this.focusInput();
});
});
});
});
requestAnimationFrame を3回ネスト——美しくはないが、動く。
なぜ3回なのか。Livewire 4 の dispatch() は内部で 3回の queueMicrotask を経由してから component.el で CustomEvent を発火する。そのイベントが window まで bubble し、Livewire.on() でキャッチ可能になる。しかしキャッチした時点ではまだ DOM の morph が完了していないため、rAF を3回重ねてブラウザの描画確定を待つ必要がある。
教訓: Livewire の内部イベントシステムは、想像以上にブラックボックスだ。ドキュメントに載っていない挙動を理解するには、ソースコードを読む覚悟が要る。
Google Fonts の罠: Box Drawing 文字が消える
ターミナルと言えば tree コマンド。罫線文字でディレクトリ構造を描画する。
~
├── about/
│ ├── company
│ ├── philosophy
│ └── team
├── services/
│ └── web-development
└── works/
├── project-alpha
└── project-beta
この ├, └, │(U+2500-259F、Box Drawing / Block Elements)が、Google Fonts の JetBrains Mono では配信されない。
画面上では罫線文字だけが空白になり、ツリー構造が崩壊した。原因特定にかなり時間がかかった。
解決策
JetBrains Mono を public/fonts/ にセルフホスト。
@font-face {
font-family: 'JetBrains Mono';
font-display: block; /* swap ではなく block */
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
font-display: block を選択したのもポイント。swap だとフォント交換の瞬間に ASCII アートが一瞬崩れて見える。CLI モードではフォントが読み込まれるまで何も表示しないほうが自然だ。
Livewire + DOMDocument: HTML5 が壊れる
Livewire 4 は root 要素の検出に PHP の DOMDocument(HTML4 パーサー)を使っている。
つまり、<header>, <main>, <section> 等の HTML5 セマンティック要素が大きな HTML の中で誤パースされる。
実際に遭遇した症状:
- Livewire の morph が途中で止まる
- コンポーネントの再レンダリングが失敗する
回避策
<!-- NG -->
<header>...</header>
<main>...</main>
<!-- OK -->
<div role="banner">...</div>
<div role="main">...</div>
また、SVG の自己閉じタグも問題になる。
<!-- NG: DOMDocument が正しくパースできない -->
<path d="M10 20..." />
<!-- OK -->
<path d="M10 20..."></path>
Livewire を使うなら、HTML5 セマンティック要素は
div + roleに置き換える。 これは公式ドキュメントには書いていない。
Designer モード: なぜ Livewire を使わなかったのか
Designer モードは CodeMirror 6 のデュアルエディタ(CSS + HTML)でサイトの見た目をリアルタイムに編集できる機能だ。
これだけ Livewire を使っていない。 スタンドアロンの Blade + Ajax で実装した。
理由は明確で、CodeMirror のようなリッチエディタは DOM を大量に操作する。Livewire の morph と衝突して、エディタの状態が吹き飛ぶ。
Livewire の morph: 「この DOM 要素は不要だな、消すか」
CodeMirror: 「ちょっと!それ俺のカーソル位置管理用の要素なんだけど!!」
独自ショートコードシステム
Blade や Livewire を使わない代わりに、独自のテンプレートエンジンを作った。
[[section.name]] → 変数展開(エスケープあり)
[[raw:content.body]] → HTML許可の変数展開
[[each:sections]]...[[/each]] → ループ
PHP と JavaScript で同一ロジックを実装しており、サーバー側レンダリングとクライアント側リアルタイムプレビューの両方に対応する。
WordPress からの SEO 移行
旧 WordPress サイトの URL 構造を 301 リダイレクトで維持している。
// routes/web.php
Route::permanentRedirect('/company/company', '/about/company');
Route::permanentRedirect('/company/philosophy', '/about/philosophy');
Route::get('/company/{slug?}', fn () => redirect('/about', 301));
Route::get('/service/{slug?}', fn () => redirect('/services', 301));
Route::get('/news/{slug?}', fn () => redirect('/blog', 301));
地味だが、これを忘れると Google のインデックスが全部 404 になる。WordPress → Laravel リニューアルで最もやってはいけないミスだ。
が、年間アクセス10にも満たない会社だからやる必要もなかった!!!!
データ設計のちょっとした工夫
body と body_cli の二重管理
contents テーブル:
body → GUI 用(HTML/Markdown)
body_cli → CLI 用(プレーンテキスト、null 許可)
body_cli が null の場合は body を strip_tags して自動生成する。CLI に最適化した文章を書きたいコンテンツだけ個別に設定できる。
metadata の JSON カラム
// contents.metadata (JSON)
{
"tech_stack": ["Laravel", "Vue.js", "Docker"],
"period": "2024-01 ~ 2024-06",
"role": "Lead Engineer"
}
cat コマンドで表示するとき、この構造化データを Tech Stack: Laravel, Vue.js, Docker のようにフォーマットして出力する。
cli_alias
sections テーブルに cli_alias カラムがあり、services セクションに srv というエイリアスを設定すると cd srv でも移動できる。ターミナルユーザーは省略形が好きだ。
Tailwind CSS v4: config 不要の世界
Tailwind v4 では tailwind.config.js が不要になった。CSS ファイル内で完結する。
@import 'tailwindcss';
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
}
設定ファイルが1つ減るだけで、プロジェクトがスッキリする。
まとめ
Livewire は「ちょうどいい」フレームワーク
React や Vue で SPA を作るほどではないが、jQuery で DOM を操作するのは辛い——そういう中間地点に Livewire はぴったりハマる。ターミナルエミュレータのような「状態を持つインタラクティブな UI」を PHP だけで作れるのは強力だ。
ただし、Livewire と大量のクライアントサイド JS を混ぜるな。Designer モードで学んだ教訓だ。
エンジニアの皆さん、自社のコーポレートサイトに neofetch を仕込むのはいかがでしょうか。
意外と、訪問者は打ちますよ。
[弊社HP](https://sai-coockara.com/)
Discussion