「無料3,000リクエスト」を使い切る! 第4回
※再現可能なソースコード全文は文末にあります。
SAKURA Internetは無料で3000リクエストも使えて太っ腹です。
夏休みの自由研究のため、チャットアプリをまとめる仕組みの奮闘の記録!
3日で800回くらいリクエストを使っていました。
こちらで付録にソース公開するための裏側の話です。
Kimi K2.6にPHPアプリを1ファイル化させたら、3日間ハマった話
AIにコードを書かせていると、
「これ、AIに任せておけばそのうち直るだろ」
と思うことがある。
今回、それをやって3日溶かした。
やったことはシンプル。
複数ファイルに分かれたPHPアプリを、最終的に1ファイルの index.php にまとめる。
使ったのは Kimi K2.6。
最初はかなり順調。
途中までの成果物も、ぱっと見ではちゃんと動いている。
ところが、バグ修正フェーズに入った瞬間、様子がおかしくなった。
そして気づいた。
AIがバグを直せないというより、問題そのものの認識がズレ始めている。
やりたかったこと
元の構成はこんな感じ。
index.html
system.txt
dotenv.php
config.php
api/
chat.php
models.php
css/
style.css
js/
app.js
これを最終的に、
out/
index.php
だけにしたい。
HTML、CSS、JavaScript、PHP、設定、API処理、system prompt。
全部を1ファイルに詰め込む。
いわゆる、
「配布するときだけ1ファイルにしたい」
というやつ。
元ファイルは残しつつ、Pythonの build.py で自動生成する方式。
ここまではKimi K2.6でも普通にいける。
ところが、バグ修正で沼る
最初の実装。
それっぽく完成。
次に動作確認。
バグ発見。
修正を依頼。
また別のバグ。
修正。
すると別の場所がおかしくなる。
修正。
すると最初の問題に戻る。
このあたりから怪しくなってきた。
特に厄介だったのが、
複数のバグが連結しているケース。
例えば、
Aの処理がおかしい
↓
Bを修正
↓
Cもおかしい
↓
Bの修正がCに影響
↓
Aの原因認識まで変わる
みたいな状態。
AI側のコンテキストでも、
問題A
↓
修正A
↓
問題B
↓
修正B
↓
問題C
↓
修正C
と履歴が積み上がっていく。
すると、
「そもそも今のコードはどういう状態なのか」
という認識が怪しくなってくる。
時間がかかる
コード全体を読み直す。
修正案を考える。
過去の変更を確認する。
またコード全体を見る。
さらに修正。
コンテキストが膨らむ。
そして、またズレる。
完全に、
AIと一緒にコードの迷路を走っている状態。
5回くらいやり直す
結局、同じような修正を5回くらい繰り返した。
しかも、
「ちょっと戻して修正」
ではない。
最終的には、
ほぼ最初からやり直す。
これを3日くらい繰り返した。
1日目。
「まあ、そのうち直るだろ」
2日目。
「いや、なんか根本的におかしい」
3日目。
「このセッションでは無理だな」
ここで諦めた。
毎回、メモリをリセット
面白かったのがここ。
毎回、
きれいな状態からやり直した。
過去の会話や途中経過に引っ張られないようにする。
新しいセッション。
新しいコンテキスト。
新しい説明。
そして同じ問題に人間が理解していること追加説明。
成果物を見ると、
「お、今回はいけそう」
となる。
でも、しばらくするとまた詰まる。
途中の成果物は「良さそう」
ここが一番危険だった。
生成されたコードを見る限り、
かなり良さそうに見える。
構造もきれい。
処理も整理されている。
コメントもある。
エラー処理もある。
一見すると完成。
ところが実際に使ってみると、
中途半端。
これがAIコーディングの怖いところ。
「コードとして成立している」
と
「要求したアプリとして成立している」
は別物。
結果を出すためにやったこと
3日目。
もう、
「ここを直して」
を繰り返すのをやめた。
やったことは単純。
ひたすら現在の現象を説明する。
例えば、
現在のソースはこれ。
この操作をするとこうなる。
期待する結果はこれ。
実際にはこうなる。
この時点では原因は分からない。
これを繰り返す。
「ここをこう直せ」とは、あまり誘導しない。
そして、人間がコンテキストを引き継ぐ
ここで一つ気づいた。
AIに、
「今までの経緯を全部理解した状態を説明した上で修正して」
とやらせるから、コンテキストを消費する。
だったら、
分かっていることを人間が整理して渡せばいい。
例えば、
現在わかっていること
・元の分割版では正常に動く
・1ファイル化するとAが発生する
・Aを修正するとBが発生する
・Bは単独では再現しない
・Cの処理は正常
・今回変更したのはbuild.pyのみ
・index.phpはbuild.pyから生成している
こういう情報をまとめて渡す。
これがかなり効いた。
AIに「考えさせる」より、AIの「現在認識」を整理する
今回、一番大きかった発見。
AIにコードを直させるとき、
修正能力だけを見ていた。
でも実際には、その前に、
「今のコードを正しく理解できているか」
がある。
ここがズレると、
間違った理解
↓
正しい修正案
↓
実装
↓
別のバグ
になる。
つまり、
正しいコードを生成しているのに、問題を解いていない。
という状態が起こる。
最終的に直った build.py
最終的に動くところまで持っていけたのが、この build.py。
やっていることは結構多い。
index.html
↓
CSSを読み込み
↓
JavaScriptを読み込み
↓
system.txtを読み込み
↓
PHP設定を読み込み
↓
API処理を読み込み
↓
必要に応じてminify
↓
必要に応じて変数名短縮
↓
HTML/CSS/JSをインライン化
↓
PHPへ埋め込み
↓
ルーティング処理を追加
↓
out/index.phpを生成
特に重要だったのが、
- CSSコメント削除
- JSコメント削除
- PHPコメント削除
- HTML/CSS/JS/PHPのminify
- 文字列リテラルの保護
- template literalの保護
- CSS変数の短縮
- JavaScript変数の短縮
- PHP変数の短縮
- API処理の関数化
- SPAルーティング
- サブディレクトリ配置への対応
- 最終生成物の1ファイル化
あたり。
単純な結合ではなく、
「壊さずに圧縮して1ファイル化するビルド処理」
になっている。
特に怖かった「文字列を守る」
minifyするとき、単純に、
re.sub(r'\s+', ' ', source)
だけやると危険。
例えば、
const text = `
hello
world
`;
みたいなtemplate literalまで壊れる。
そこで、
文字列を一旦マーカーに置換
↓
空白を圧縮
↓
マーカーを元に戻す
という方式にした。
例えば概念的には、
"hello world"
↓
__PROTECTED_0__
↓
minify
↓
__PROTECTED_0__
↓
"hello world"
という流れ。
AIとのやり取りの中で、こういう細かい部分まで積み上がっていった。
変数名短縮もやった
さらに、
longVariableName
を、
a
のように短縮する処理まで入れた。
ただし、何でも短縮すると壊れる。
そこで、
予約語
グローバル変数
DOMに関連する名前
外部ライブラリ
PHP組み込み関数
プロジェクト固有の関数
などを保護。
このあたりは正規表現ベースなので、当然ながらASTほど安全ではない。
それでも、
「1ファイルにまとめて、さらに小さくしたい」
という用途ではかなり面白い。
そして、今回の本当の収穫
3日かかった。
5回くらいやり直した。
リクエストも大量に消費した。
最初は、
「Kimi K2.6にはこの仕事は難しい」
と思った。
でも、少し違った。
正確には、
「コンテキストをAIに持たせ続けた状態で、複数バグが絡む修正をさせ続けるのが難しい」
だった。
AIの能力だけの問題ではなかった。
AIコーディングでハマったら「修正」より「現状整理」
今回の経験から、個人的にはこの順番がかなり重要だと思っている。
×ここを直して
↓
× 直った?
↓
× じゃあ次はここ
↓
× あれ、別のところが壊れた
ではなく、
○ 現在のコード
○ 再現条件
○ 実際の現象
○ 期待する結果
○ すでに分かっていること
○ まだ分かっていないこと
を先に整理。
そのうえでAIに渡す。
つまり、
AIに問題を解かせる前に、人間が問題の状態を固定する。
これが今回かなり効いた。
AIに全部覚えさせる必要はない
AIエージェントが強くなって、
「全部AIに任せればいい」
という空気もある。
実際、単純な実装ならかなり任せられる。
でも、複数のバグが絡み始めると話が変わる。
コンテキストが増える。
仮説が増える。
修正履歴が増える。
そして、
「今なにが分かっているのか」
が曖昧になる。
ここで人間が一度、
現在地
↓
分かっていること
↓
分かっていないこと
↓
再現条件
↓
期待値
を整理する。
これだけで、AIとのラリーがかなり変わる。
結論
今回の教訓。
AIにコードを書かせる能力だけでは足りない。
重要なのは、
AIが今どこで迷っているかを人間が把握する能力。
特に、
- 大量のコード
- 複数ファイル
- 複数のバグ
- 何度も修正
- 長いコンテキスト
- リファクタリング
- 自動生成コード
このあたりが重なると、
「もっとAIに考えさせる」
より、
「人間が現在地を整理してAIに渡す」
ほうが効く場面がある。
今回の3日間は、
Kimi K2.6に負けた3日間でもあり、
AIコーディングの使い方を一段理解した3日間でもあった。
そして最終的に残ったのは、
分割されたPHPアプリ
↓
build.py
↓
全部入り
↓
out/index.php
という、なかなか強引な成果物。
AIに1ファイル化を頼んだら、
3日後に人間のほうがデバッグの作法を学んでいた。
付録 build.py ソースコード
| コマンド | 出力サイズ |
|---|---|
| python build.py | 64,710 bytes |
| python build.py --var | 62,852 bytes |
| python build.py --crlf | 54,182 bytes |
| python build.py --var --crlf | 52,324 bytes |
20%削減
build.py クリックすると全文表示されます。
#!/usr/bin/env python3
"""
Build script: merge all split files into a single packed index.php.
Usage: python build.py [--crlf] [--var]
The existing split files are NOT modified; only index.php is generated.
With --crlf: aggressively collapse all whitespace (removes newlines).
With --var: shorten variable / function / CSS property names.
"""
import os
import re
import sys
DIR = os.path.dirname(os.path.abspath(__file__))
_MARKER_PREFIX = '__PROT_a1b2c3d4e5__'
_MARKER_SUFFIX = '__PROT_A1B2C3D4E5__'
def _strip_comments_css(source: str) -> str:
return re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
def _strip_comments_js(source: str) -> str:
source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
source = re.sub(r'^[ \t]*//.*$', '', source, flags=re.MULTILINE)
return source
def _strip_comments_php(source: str) -> str:
source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
source = re.sub(r'^[ \t]*//.*$', '', source, flags=re.MULTILINE)
return source
def compact_css(source: str) -> str:
"""Remove comments / trailing whitespace / compress 3+ blank lines to 2."""
source = _strip_comments_css(source)
source = '\n'.join(line.rstrip() for line in source.splitlines())
source = re.sub(r'\n{3,}', '\n\n', source)
return source
def compact_js(source: str) -> str:
"""Remove // line comments and /* */ blocks, trailing whitespace, blank lines."""
source = _strip_comments_js(source)
source = '\n'.join(line.rstrip() for line in source.splitlines())
source = re.sub(r'\n{3,}', '\n\n', source)
return source
def compact_php(source: str) -> str:
"""Remove // line comments and /* */ blocks, trailing whitespace, blank lines."""
source = _strip_comments_php(source)
source = '\n'.join(line.rstrip() for line in source.splitlines())
source = re.sub(r'\n{3,}', '\n\n', source)
return source
def compact_html(source: str) -> str:
"""Remove trailing whitespace, compress 3+ blank lines to 2."""
source = '\n'.join(line.rstrip() for line in source.splitlines())
source = re.sub(r'\n{3,}', '\n\n', source)
return source
def _minify_single_pass(source: str, marker_prefix: str, strip_comments, *patterns) -> str:
"""Protect all literal/tag patterns, collapse whitespace, then restore.
Each pattern is processed separately so DOTALL applies only where needed:
- tag blocks and template literals: DOTALL (may span lines)
- quote strings: no DOTALL (must stay on one line)
"""
prefix = f'__{marker_prefix}_'
suffix = f'_{marker_prefix}__'
protected = []
def protect(m):
token = m.group(0)
if token.startswith(chr(96)) and ('\n' in token or '\r' in token):
# In --crlf mode, template literal newlines become a single space
token = token.replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ')
protected.append(token)
return f'{prefix}{len(protected) - 1}{suffix}'
if strip_comments:
source = strip_comments(source)
# Protect each pattern individually with appropriate flags
for pat in patterns:
# DOTALL only for patterns that legitimately span lines:
# <tag>...</tag> blocks, `...` template literals
needs_dotall = pat.startswith(r'<') or pat.startswith(r'`')
flags = re.DOTALL if needs_dotall else 0
source = re.sub(pat, protect, source, flags=flags)
source = re.sub(r'\s+', ' ', source)
def restore(m):
idx = int(m.group(1))
return protected[idx]
source = re.sub(re.escape(prefix) + r'(\d+)' + re.escape(suffix), restore, source)
return source.strip()
def minify_html(source: str) -> str:
"""Aggressive: collapse whitespace between tags while preserving <pre>/<textarea>/<script>/<style> contents."""
return _minify_single_pass(
source, 'HTML', None,
r'<pre[^>]*>.*?</pre>', # <pre> blocks
r'<textarea[^>]*>.*?</textarea>', # <textarea> blocks
r'<script[^>]*>.*?</script>', # <script> blocks
r'<style[^>]*>.*?</style>', # <style> blocks
r'"(?:[^"\\]|\\.)*?"', # double-quoted strings
r"'(?:[^'\\]|\\.)*?'", # single-quoted strings
)
def minify_css(source: str) -> str:
"""Aggressive: strip comments and collapse all non-string whitespace to a single space."""
return _minify_single_pass(
source, 'CSS', _strip_comments_css,
r'"(?:[^"\\]|\\.)*?"', # double-quoted strings
r"'(?:[^'\\]|\\.)*?'", # single-quoted strings
)
def minify_js(source: str) -> str:
"""Aggressive: strip comments and collapse all non-string whitespace to a single space."""
return _minify_single_pass(
source, 'JS', _strip_comments_js,
r'`(?:[^`\\]|\\.)*?`', # template literals
r'"(?:[^"\\]|\\.)*?"', # double-quoted strings
r"'(?:[^'\\]|\\.)*?'", # single-quoted strings
)
def minify_php(source: str) -> str:
"""Aggressive: strip comments and collapse all non-string whitespace to a single space."""
return _minify_single_pass(
source, 'PHP', _strip_comments_php,
r'`(?:[^`\\]|\\.)*?`', # backtick strings
r'"(?:[^"\\]|\\.)*?"', # double-quoted strings
r"'(?:[^'\\]|\\.)*?'", # single-quoted strings
)
def _short_name_generator():
"""Yield a, b, c, ..., z, aa, ab, ..."""
import itertools, string
chars = string.ascii_lowercase
for length in itertools.count(1):
for combo in itertools.product(chars, repeat=length):
yield ''.join(combo)
def shorten_css_vars(source: str) -> str:
"""Shorten CSS custom properties (--name → --a, --b, ...) and all var() refs."""
declared = set(re.findall(r'(?<=[\s;{])(--[a-zA-Z][a-zA-Z0-9-]*)\s*:', source))
used = set(re.findall(r'var\(\s*(--[a-zA-Z][a-zA-Z0-9-]*)', source))
candidates = declared & used
if not candidates:
return source
gen = _short_name_generator()
mapping = {}
for old in sorted(candidates):
mapping[old] = '--' + next(gen)
for old in sorted(mapping.keys(), key=len, reverse=True):
source = re.sub(rf'(?<![a-zA-Z0-9_-]){re.escape(old)}(?![a-zA-Z0-9_-])', mapping[old], source)
return source
def _collect_js_globals_and_refs(source: str) -> set:
"""Collect identifiers referenced from DOM / global scope / API calls."""
protected = set()
# getElementById, querySelector, querySelectorAll, hasOwnProperty refs
for m in re.finditer(r"(getElementById|querySelector|querySelectorAll|addEventListener|getAttribute|setAttribute|removeAttribute)\s*\(\s*['\"]([^'\"]+)['\"]", source):
protected.add(m.group(2))
# window.xxx, document.xxx, localStorage.xxx etc.
for m in re.finditer(r"\b(window|document|localStorage|navigator|console|marked|DOMPurify|hljs|mermaid|Chart|JSON|URL|Blob|Date|Math)\b", source):
protected.add(m.group(1))
# All single-word string literals used as property names (conservative)
for m in re.finditer(r"\.(\w+)\b", source):
protected.add(m.group(1))
# Event names in string literals
for m in re.finditer(r"['\"](click|input|keydown|change|load|error|beforeunload)['\"]", source):
protected.add(m.group(1))
return protected
def shorten_js_vars(source: str) -> str:
"""Shorten local-scoped JS identifiers. Global / DOM-linked names are protected."""
# Reserved words & built-ins that must never be touched
reserved = {
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new',
'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var',
'void', 'while', 'with', 'yield', 'await', 'async', 'of', 'static',
'get', 'set', 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
'arguments', 'eval', 'Array', 'Boolean', 'Date', 'Error', 'Function',
'JSON', 'Math', 'Number', 'Object', 'Promise', 'RegExp', 'String',
'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', 'console', 'document',
'window', 'localStorage', 'navigator', 'location', 'history', 'screen',
'self', 'top', 'parent', 'globalThis', 'setTimeout', 'clearTimeout',
'setInterval', 'clearInterval', 'parseInt', 'parseFloat', 'isNaN',
'isFinite', 'encodeURI', 'decodeURI', 'encodeURIComponent',
'decodeURIComponent', 'escape', 'unescape', 'alert', 'confirm', 'prompt',
'fetch', 'Headers', 'Request', 'Response', 'FormData', 'URLSearchParams',
'Blob', 'File', 'FileReader', 'ArrayBuffer', 'Uint8Array', 'Int8Array',
'Uint16Array', 'Int16Array', 'Uint32Array', 'Int32Array', 'Float32Array',
'Float64Array', 'DataView', 'TextEncoder', 'TextDecoder', 'Event',
'CustomEvent', 'MutationObserver', 'IntersectionObserver', 'ResizeObserver',
'Performance', 'performance', 'Reflect', 'Proxy', 'Intl', 'WebSocket',
'Worker', 'SharedArrayBuffer', 'Atomics', 'URL', 'URLPattern',
# External library globals
'marked', 'DOMPurify', 'hljs', 'mermaid', 'Chart',
}
protected = reserved | _collect_js_globals_and_refs(source)
# Protect all words appearing inside string literals (they may be CSS class names etc.)
for m in re.finditer(r"'([^'\\]*(?:\\.[^'\\]*)*)'", source):
for word in re.finditer(r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b', m.group(1)):
protected.add(word.group(1))
for m in re.finditer(r'"([^"\\]*(?:\\.[^"\\]*)*)"', source):
for word in re.finditer(r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b', m.group(1)):
protected.add(word.group(1))
# Find all declarations: const x =, let x =, var x =, function x(
declared = set()
for m in re.finditer(r'\b(const|let|var|function)\s+(\w+)', source):
name = m.group(2)
if name not in protected and not name.startswith('_'):
declared.add(name)
# Find all usages: word tokens that are not reserved
used = set()
for m in re.finditer(r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b', source):
name = m.group(1)
if name in declared:
used.add(name)
# Only shorten names that are declared AND used, and not protected
candidates = {n for n in declared if n in used}
if not candidates:
return source
gen = _short_name_generator()
mapping = {}
# Sort for stability; longer names first to avoid partial collision
for old in sorted(candidates, key=len, reverse=True):
# skip very short names (<=2 chars: already short)
if len(old) <= 2:
continue
new = next(gen)
# ensure the generated name is not in protected
while new in protected:
new = next(gen)
mapping[old] = new
if not mapping:
return source
def replacer(m):
name = m.group(1)
return mapping.get(name, name)
# Word boundary replacement
pattern = re.compile(r'\b(' + '|'.join(re.escape(k) for k in mapping) + r')\b')
return pattern.sub(replacer, source)
def _collect_php_globals(source: str) -> set:
"""Collect PHP superglobals, class names, and function names that must stay."""
protected = {
'self', 'parent', 'static', 'true', 'false', 'null',
'array', 'callable', 'iterable', 'object', 'string', 'int', 'float', 'bool',
'mixed', 'void', 'never', 'readonly',
'$_GET', '$_POST', '$_REQUEST', '$_SESSION', '$_COOKIE',
'$_SERVER', '$_ENV', '$_FILES', '$_GLOBALS',
'class', 'function', 'public', 'private', 'protected', 'static',
'return', 'if', 'else', 'elseif', 'while', 'for', 'foreach', 'do',
'switch', 'case', 'default', 'break', 'continue', 'throw', 'try',
'catch', 'finally', 'goto', 'declare', 'use', 'namespace', 'interface',
'trait', 'extends', 'implements', 'abstract', 'final', 'yield', 'clone',
'instanceof', 'insteadof', 'isset', 'unset', 'empty', 'echo', 'print',
'and', 'or', 'xor', 'as', 'new', 'list', 'include', 'include_once',
'require', 'require_once', 'die', 'exit', 'eval', 'var', 'global',
'unset', 'const', 'define', 'defined', 'dirname', 'basename', 'file_exists',
'file_get_contents', 'file_put_contents', 'json_encode', 'json_decode',
'error_log', 'trim', 'str_starts_with', 'str_replace', 'strtolower',
'explode', 'implode', 'array_key_exists', 'putenv', 'getenv', 'curl_init',
'curl_setopt', 'curl_exec', 'curl_getinfo', 'curl_close', 'header',
'http_response_code', 'strlen', 'strpos', 'substr', 'rtrim', 'uniqid',
'parse_url', 'is_file', 'pathinfo', 'mime_content_type', 'readfile',
'in_array', 'preg_match', 'preg_replace', 'preg_replace_callback',
'preg_match_all', 'preg_split', 'preg_quote', 'date', 'time', 'microtime',
'round', 'sleep', 'usleep', 'rand', 'mt_rand', 'srand', 'mt_srand',
'abs', 'min', 'max', 'ceil', 'floor', 'count', 'sizeof', 'array_keys',
'array_values', 'array_merge', 'array_filter', 'array_map', 'array_reduce',
'array_slice', 'array_splice', 'array_shift', 'array_pop', 'array_unshift',
'array_push', 'array_flip', 'array_reverse', 'array_unique', 'array_search',
'sort', 'rsort', 'asort', 'arsort', 'ksort', 'krsort', 'usort', 'uasort',
'uksort', 'shuffle', 'range', 'compact', 'extract', 'end', 'reset',
'current', 'key', 'next', 'prev', 'each', 'array_walk', 'array_walk_recursive',
'array_sum', 'array_product', 'array_chunk', 'array_column', 'array_fill',
'array_fill_keys', 'array_replace', 'array_replace_recursive',
'array_intersect', 'array_intersect_key', 'array_intersect_assoc',
'array_diff', 'array_diff_key', 'array_diff_assoc',
'array_combine', 'array_pad', 'array_rand', 'array_multisort',
# Project-level identifiers that must stay
'Config', 'loadEnv', 'handleApiChat', 'handleApiModels',
'packedSystemText', 'packedHtml', 'maskHeader', 'logDebug',
}
# Detect function declarations
for m in re.finditer(r'\bfunction\s+(\w+)\s*\(', source):
protected.add(m.group(1))
# Detect class declarations
for m in re.finditer(r'\bclass\s+(\w+)\b', source):
protected.add(m.group(1))
# Detect static method calls: ClassName::
for m in re.finditer(r'\b(\w+)::', source):
protected.add(m.group(1))
return protected
def shorten_php_vars(source: str) -> str:
"""Shorten PHP local variables ($name). Class props / superglobals stay."""
protected = _collect_php_globals(source)
# All $var usages
all_vars = set(re.findall(r'\$(\w+)', source))
# Remove protected
candidates = {v for v in all_vars if v not in protected and not v.startswith('_')}
if not candidates:
return source
gen = _short_name_generator()
mapping = {}
for old in sorted(candidates, key=len, reverse=True):
if len(old) <= 2:
continue
new = next(gen)
while new in protected:
new = next(gen)
mapping[old] = new
if not mapping:
return source
def replacer(m):
name = m.group(1)
return '$' + mapping.get(name, name)
pattern = re.compile(r'\$(' + '|'.join(re.escape(k) for k in mapping) + r')\b')
return pattern.sub(replacer, source)
def read_file(path):
with open(path, 'r', encoding='utf-8') as f:
return f.read().replace('\r\n', '\n').replace('\r', '\n')
def php_var_export(s):
"""Python equivalent of PHP var_export for a string (double-quoted)."""
escaped = (s.replace('\\', '\\\\')
.replace('"', '\\"')
.replace('$', '\\$')
.replace('\r', '\\r')
.replace('\n', '\\n'))
return '"' + escaped + '"'
def main():
use_minify = '--crlf' in sys.argv
use_var = '--var' in sys.argv
# 1. Read source files
html_raw = read_file(os.path.join(DIR, 'index.html'))
css = read_file(os.path.join(DIR, 'css', 'style.css'))
js = read_file(os.path.join(DIR, 'js', 'app.js'))
system_txt = read_file(os.path.join(DIR, 'system.txt'))
dotenv = read_file(os.path.join(DIR, 'dotenv.php'))
config = read_file(os.path.join(DIR, 'config.php'))
chat = read_file(os.path.join(DIR, 'api', 'chat.php'))
models = read_file(os.path.join(DIR, 'api', 'models.php'))
# 2. Compact or minify CSS / JS / HTML
if use_var:
css = shorten_css_vars(css)
js = shorten_js_vars(js)
if use_minify:
css = minify_css(css)
js = minify_js(js)
html_raw = minify_html(html_raw)
else:
css = compact_css(css)
js = compact_js(js)
html_raw = compact_html(html_raw)
# 3. Inline CSS / JS into HTML
if use_minify:
# CRLF mode: no newlines (minify_html tag protection preserves inner newlines)
html_raw = re.sub(
r'<link rel="stylesheet" href="css/style\.css">',
lambda m: f'<style> {css} </style>',
html_raw
)
html_raw = re.sub(
r'<script src="js/app\.js"></script>',
lambda m: f'<script> {js} </script>',
html_raw
)
else:
# Compact mode: keep newlines for readability
html_raw = re.sub(
r'<link rel="stylesheet" href="css/style\.css">',
lambda m: f'<style>\n{css}\n</style>',
html_raw
)
html_raw = re.sub(
r'<script src="js/app\.js"></script>',
lambda m: f'<script>\n{js}\n</script>',
html_raw
)
html_escaped = php_var_export(html_raw)
system_escaped = php_var_export(system_txt)
# 4. Strip leading <?php / trailing ?> and require_once, then compact PHP
def strip_php(code):
code = code.strip()
if code.startswith('<?php'):
code = code[5:].lstrip()
if code.endswith('?>'):
code = code[:-2].rstrip()
code = re.sub(r'^require_once .+?;\s*\n?', '', code, flags=re.MULTILINE)
return code
_php = minify_php if use_minify else compact_php
dotenv = _php(strip_php(dotenv))
config = _php(strip_php(config))
chat = _php(strip_php(chat))
models = _php(strip_php(models))
if use_var:
dotenv = shorten_php_vars(dotenv)
config = shorten_php_vars(config)
chat = shorten_php_vars(chat)
models = shorten_php_vars(models)
# 5. Wrap chat.php into handleApiChat()
chat_lines = chat.splitlines()
indented_chat = '\n'.join(' ' + line for line in chat_lines)
indented_chat = re.sub(r'\bexit\s*;', 'return;', indented_chat)
chat_wrapped = f'function handleApiChat(): void {{\n{indented_chat}\n}}\n'
# 6. Wrap models.php into handleApiModels()
models_lines = models.splitlines()
indented_models = '\n'.join(' ' + line for line in models_lines)
models_wrapped = f'function handleApiModels(): void {{\n{indented_models}\n}}\n'
# 7. Assemble
parts = ["""<?php
// =========================================================================
// This file is auto-generated by build.py.
// Do NOT edit directly; run `python build.py` to regenerate.
// =========================================================================
"""]
parts.append('\n// ===== dotenv =====\n' + dotenv)
parts.append('\n// ===== Config =====\n' + config)
parts.append('\n// ===== Chat Handler =====\n' + chat_wrapped)
parts.append('\n// ===== Models Handler =====\n' + models_wrapped)
parts.append('\n// ===== System Text =====\n')
parts.append(f'function packedSystemText(): string {{\n return {system_escaped};\n}}\n')
parts.append('\n// ===== Packed HTML =====\n')
parts.append(f'function packedHtml(): string {{\n return {html_escaped};\n}}\n')
parts.append("""
// =========================================================================
// Router (single-file front controller)
// =========================================================================
// Base-path auto-detection (supports sub-directory deployment)
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/';
$basePath = dirname($scriptName);
$basePath = ($basePath === '/' || $basePath === '\\\\') ? '' : $basePath;
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$relativeUri = ($basePath !== '' && str_starts_with($uri, $basePath))
? substr($uri, strlen($basePath))
: $uri;
$relativeUri = $relativeUri ?: '/';
$apiEndpoint = Config::get('API_ENDPOINT', '/api/chat');
if ($relativeUri === $apiEndpoint) {
handleApiChat();
exit;
}
if ($relativeUri === '/api/models') {
handleApiModels();
exit;
}
if ($relativeUri === '/system.txt') {
header('Content-Type: text/plain; charset=utf-8');
echo packedSystemText();
exit;
}
// SPA shell (all other routes served as index.html)
if ($relativeUri === '/' || $relativeUri === '/index.html') {
header('Content-Type: text/html; charset=utf-8');
echo packedHtml();
exit;
}
// 404
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'Not Found']);
""")
packed = ''.join(parts)
if use_minify:
# Final pass: strip line comments first (required because the template
# contains // comments; without newlines they would comment out the rest
# of the file), then protect string literals, collapse whitespace, restore.
packed = _strip_comments_php(packed)
packed = _minify_single_pass(
packed, 'FINAL', None,
r'"(?:[^"\\]|\\.)*?"',
r"'(?:[^'\\]|\\.)*?'",
r'`(?:[^`\\]|\\.)*?`',
)
out_path = os.path.join(DIR, 'blog', 'index.php')
with open(out_path, 'w', encoding='utf-8') as f:
f.write(packed)
size = os.path.getsize(out_path)
print(f'Generated: {out_path}')
print(f'Size: {size:,} bytes')
if __name__ == '__main__':
main()
他のPJで利用する場合はmain()をPJのファイルに合わせて書き換えが必要です。
最後の「# 7. Assemble」で力技で動くようにしたました・・・