# テンプレートエンジン 利用ガイド

`classes/vendors/Framework/Template/` に配置されたテンプレートエンジンの使い方をまとめます。
テンプレート側の構文は [03_html_template_guide.md](03_html_template_guide.md) を参照してください。

---

## 目次

1. [設計方針](#1-設計方針)
2. [パッケージ構成](#2-パッケージ構成)
3. [フレームワークとの接続](#3-フレームワークとの接続)
4. [ViewData —— Model が使う唯一のAPI](#4-viewdata--model-が使う唯一のapi)
5. [エスケープ責務](#5-エスケープ責務)
6. [View の2段レンダリング](#6-view-の2段レンダリング)
7. [レイアウトの切り替え](#7-レイアウトの切り替え)
8. [名前空間 —— `_` の有無](#8-名前空間--_-の有無)
9. [処理パイプラインとキャッシュ](#9-処理パイプラインとキャッシュ)
10. [例外](#10-例外)
11. [よくある落とし穴](#11-よくある落とし穴)

---

## 1. 設計方針

| 方針 | 内容 |
|---|---|
| **テンプレートにロジックを持たせない** | 書式整形・URLの組み立て・選択状態の判定はすべて Model で行い、テンプレートには判定と出力だけを残す |
| **エスケープは Model が担う** | ただし `htmlspecialchars()` は書かせない。`ViewData` の格納APIを使い分けることで完了させる |
| **エンジンはフレームワークを知らない** | `Request` / `Session` / DB接続 / ルーティングを一切受け取らない。`$_GET` / `$_POST` / `$_SERVER` を参照しない |
| **異常はコンパイル時に落とす** | タグの不一致、未知の修飾子、閉じられないコメント、part の循環参照はすべてコンパイル時エラー |
| **二重パースをしない** | レンダリング結果を再度スキャンして置換文字を探すことはしない(XSS の温床になるため) |

---

## 2. パッケージ構成

```
classes/vendors/Framework/Template/
    TemplateEngine.php                    公開API(このクラスだけを外から呼ぶ)
    TemplateConfig.php                    設定インターフェース
    ViewData.php                          Model が組み立てるデータコンテナ
    Runtime.php                           コンパイル済みコードから呼ばれる静的ヘルパ
    TemplateLoader.php                    テンプレートファイルの読み込みとパス検証
    TemplatePreprocessor.php              コメント除去と part の再帰展開
    TemplateLexer.php                     置換文字のトークン分解
    Token.php                             トークン
    TemplateParser.php                    トークン列 → 構文木
    Node.php                              構文木のノード
    TemplateCompiler.php                  構文木 → PHPコード
    TemplateCache.php                     コンパイル結果の保存と鮮度判定
    TemplateException.php                 基底例外(RuntimeException 継承)
    TemplateNotFoundException.php
    TemplateSyntaxException.php
    TemplateCircularReferenceException.php
```

`classes/core/View.php` がこのパッケージのファサードです。

### 2.1 公開API

```php
namespace Framework\Template;

class TemplateEngine {
	public function __construct($templateRoot, $cacheDir, $debug = false)
	public static function fromConfig(TemplateConfig $config)
	public function render($path, ViewData $data)
	public function clearCache()
}
```

**エンジンの公開APIはこれだけです。**

---

## 3. フレームワークとの接続

### 3.1 オートロード

`bootstrap.php` で PSR-4 相当の登録をしています。

```php
//テンプレートエンジン(名前空間付きのクラス)を読み込めるように対応を登録する
$loader->registerNamespace('Framework\\', dirname(__FILE__).'/classes/vendors/Framework');
```

### 3.2 View がエンジンを生成する

`classes/core/View.php` のコンストラクタでエンジンを生成します。

```php
public function __construct($templateDir, $cacheDir, $debug=false, $viewDirectory='view') {
	//テンプレートエンジンのインスタンスを生成する
	$this->engine = new TemplateEngine($templateDir, $cacheDir, $debug);
	$this->viewDirectory = trim(str_replace('\\', '/', $viewDirectory), '/');
}
```

### 3.3 Model が View を生成する

`classes/core/Model.php` のコンストラクタで、Application から供給された設定を使って生成します。

```php
//Applicationから供給された設定でViewを生成する
$this->view = new View(
	$application->getTemplateDir(),        // <root>/templates
	$application->getTemplateCacheDir(),   // <root>/cache/template
	$application->isDebugMode(),           // デバッグモード
	$application->getViewDirectory()       // view/shop など
);
```

**接続点はこの2箇所だけ**です。Application が「テンプレートルート・キャッシュ先・デバッグ・Viewディレクトリ」の
4つを供給できれば、エンジンは動きます。

---

## 4. ViewData —— Model が使う唯一のAPI

Model は `ViewData` に値を詰め、`Model::render()` へ渡します。

| メソッド | エスケープ | 用途 |
|---|---|---|
| `set($name, $value)` | **する** | 出力用のスカラー値。**配列・オブジェクトを渡すと例外** |
| `setRaw($name, $value)` | しない | エスケープ済み、または安全性を確認済みのHTML |
| `setRows($name, array $rows, array $rawColumns = array())` | **する**(再帰) | ループ用の行配列。`$rawColumns` で列単位に除外指定 |
| `setFlag($name, $value)` | しない | 判定専用で出力しない値。非ASCII の判定値に使う |
| `setForm(array $values)` | **する**(再帰) | フォーム再表示値(GET/POST データ) |
| `vars()` / `form()` / `rawKeys()` | — | エンジンおよび監査用 |

格納系メソッドは自身を返すため、メソッドチェーンで書けます。

### 4.1 実際の使い分け(`AdminShippingModel::renderIndex()`)

```php
$data = $this->createAdminViewData('送料設定', 'shipping');

//設定フォームの値を設定する
$data->setForm($input);
$data->setRows('_carrierOptions', $this->buildCarrierOptionRows($this->pickValue($input, 'defaultCarrierId')));

//配送業者の一覧を設定する
$carriers = $this->buildCarrierRows();
$data->setRows('_carriers', $carriers);
//一覧の件数を表示用の文言として設定する
$data->set('_carrierTotal', count($carriers).'件');

//メッセージとエラーを設定する
$data->setRows('_messages', $this->buildMessageRows($messages));
$data->setRows('_errors', $this->buildMessageRows($errors));

//フォームの送信先とCSRFトークンを設定する
$data->set('_settingUrl', $baseUrl.'/shipping');
$data->set('_token', $token);

return $this->renderAdmin('shipping/index', $data);
```

### 4.2 行配列の作り方

ループ行のフィールド名にも **`_` プリフィックスを必須** とします。

```php
/**
 * 配送業者一覧の表示用データを組み立てる関数
 * @author S.Morikane
 * @return array
 */
protected function buildCarrierRows() {
	$rows = array();
	$baseUrl = $this->request->getBaseUrl();
	$feeRepository = $this->repository('ShippingFee');
	$setting = $this->fetchSetting();

	//配送業者分ループして表示用の行を組み立てる
	foreach($this->repository('Carrier')->fetchAllForAdmin() as $carrier) {
		$carrierId = (int)$carrier['id'];
		$missing = $feeRepository->countMissing($carrierId);

		$rows[] = array(
			'_id'=>$carrierId,
			'_name'=>$carrier['name'],
			'_sortOrder'=>(int)$carrier['sort_order'],
			//利用状態で表示を切り替える
			'_active'=>((int)$carrier['status'] === 1),
			//既定の配送業者には目印を付ける
			'_isDefault'=>((string)$carrierId === (string)$setting['default_carrier_id']),
			//送料が未登録の都道府県があれば知らせる
			'_hasMissing'=>($missing > 0),
			'_missing'=>$missing,
			'_editUrl'=>$baseUrl.'/shipping/carrier/edit/'.$carrierId,
			'_feeUrl'=>$baseUrl.'/shipping/fee/'.$carrierId,
		);
	}

	return $rows;
}
```

**ポイント**

- `_active` / `_isDefault` / `_hasMissing` のように、**判定結果を bool で持たせる**。
  テンプレート側で条件式を書かせないため
- `_editUrl` / `_feeUrl` のように、**URL も組み立てて渡す**。
  テンプレートがフロントコントローラの設置場所を知らずに済む

---

## 5. エスケープ責務

### 5.1 エスケープ仕様

```php
htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
```

- `ENT_QUOTES` 固定。シングルクォート属性 `value='...'` でも破綻しない
- `setRows()` / `setForm()` は配列を再帰的に走査し、**葉のスカラー値のみ**をエスケープする(配列構造は保持)
- `null` はエスケープせずそのまま保持する(未定義判定に使うため)。出力時は空文字になる
- **エンジンは出力時にエスケープしない。** `ViewData` 格納時に完了しているため

### 5.2 `setRaw()` の使いどころ

`setRaw()` は grep 可能な単一キーワードであり、レビュー対象を機械的に列挙できます。
フレームワーク内での使用は現在1箇所だけです。

```php
//レンダリング済みの文字列をレイアウトへ渡す
//既にエスケープ済みのHTMLであり、再解析も行わないため setRaw を使用する
$data->setRaw(self::CONTENTS, $content);
```

### 5.3 `setFlag()` の使いどころ

真偽値判定で使う判定値は、エスケープ済みの値と比較されます。
そのため **判定値には英数字とアンダースコアのみを使う** ことを規約にしています。
非ASCII や `&` `<` `"` を判定値にしたい場合のみ `setFlag()` を使います。

```php
//権限は出力せず判定にのみ使うため setFlag() で格納する
$data->setFlag('_role', array('admin', 'leader'));
```

```html
{_role:admin,leader:y}管理者またはリーダーにだけ見せる内容{/_role:admin,leader:y}
```

### 5.4 URL コンテキストの注意

`href` / `action` / `src` に入る値は、HTMLエスケープだけでは `javascript:` を防げません。
**Model 側でスキーム検証を行ってから `set()` してください。** エンジンはコンテキストを判別しません。

本プロジェクトの URL は Model が `getBaseUrl()` を起点に組み立てているため、
外部由来の文字列がそのまま `href` に入ることはありません。

---

## 6. View の2段レンダリング

```php
public function render($path, ViewData $data, $layout='layout') {
	//まずViewを単独でレンダリングする
	$content = $this->engine->render($this->buildViewPath($path), $data);

	//レイアウトの指定が無ければViewの結果をそのまま返す
	if($layout === null || $layout === false || $layout === '') {
		return $content;
	}

	//レンダリング済みの文字列をレイアウトへ渡す
	//既にエスケープ済みのHTMLであり、再解析も行わないため setRaw を使用する
	$data->setRaw(self::CONTENTS, $content);

	//レイアウトもアプリケーションのViewディレクトリ配下から解決する
	return $this->engine->render($this->buildViewPath($layout), $data);
}
```

1. View テンプレートを **単独で** レンダリングして完成済みHTMLを得る
2. その文字列を `_contents` として `ViewData` に載せる
3. レイアウトをレンダリングし、`{_contents}` の位置へ出力する

`{_contents}` は **レイアウトのコンパイル済みコードの中で変数として出力** されます。
文字列置換で埋め込んでから全体を再解析することはありません。
そのため View の描画結果に含まれるユーザー入力が置換文字として再解釈されることはありません。

> **レイアウトと View は同じ `ViewData` を共有します。**
> Model が設定した `_title` や `_baseUrl` は、レイアウト側からもそのまま参照できます。

### 6.1 `part` と `_contents` の違い

| | `{part:...}` | `{_contents}` |
|---|---|---|
| 対象 | header / footer / カード部品などデザイン側の共通パーツ | Model が選択した View のレンダリング結果 |
| 合成タイミング | データを流し込む **前**(コンパイル時のソース合成) | View を先に単独でレンダリングし終えた **後** の完成済み文字列 |
| 変数スコープ | 貼り付け先と同じスコープを共有 | 別スコープ(完成済み文字列を挿入するのみ) |
| 再パース | 貼り付け後、1つのソースとして通常通り解析される | **再度置換文字として解釈しない** |

---

## 7. レイアウトの切り替え

### 7.1 既定のレイアウト

`Model::render($path, $data)` は `layout` を使います。
Viewディレクトリが `view/shop` なら `templates/view/shop/layout.html` です。

### 7.2 レイアウトを差し替える

```php
//ログイン画面はグローバルナビゲーションを出さないため専用レイアウトを使う
return $this->render('login', $data, 'layout_bare');
```

### 7.3 レイアウトなしで描画する

`$layout` に `null` を渡すと、レイアウトを挟まず View の描画結果だけを返します。

```php
//404ページはレイアウトを使わずに単体で完結したHTMLとして描画する
return $view->render('error/404', $data, null);
```

Ajax レスポンスや部分更新にも同じ仕組みを使います。

#### 7.3.1 基本の考え方 —— 差し替える範囲を部品として切り出す

差し替えたい範囲を `_parts/` の部品として切り出し、
**「全体描画用の View」と「断片だけを返す View」の両方から `{part:...}` で取り込みます。**
こうすることで、同じHTMLが2箇所に重複しません。

```
templates/view/shop/
    layout.html                    レイアウト
    _parts/
        header.html                {part:cart_badge} で取り込む(同じ _parts/ 内なのでディレクトリ指定なし)
        cart_badge.html            ★差し替える範囲(共通部品)
    cart_badge.html                Ajax用のView({part:_parts/cart_badge} だけを書く)
    product/
        _parts/
            product_grid.html      ★差し替える範囲(product/ 専用部品)
        index.html                 全体描画用のView
        list.html                  部分更新用のView({part:_parts/product_grid} だけを書く)
```

> **part の相対パスは「呼び出し元ファイルと同じディレクトリ」が基準です。**
> `_parts/header.html` から同じ `_parts/` 内の部品を取り込む場合は `{part:cart_badge}` になります
> (`{part:_parts/cart_badge}` と書くと `_parts/_parts/cart_badge.html` を探しに行きます)。

#### 7.3.2 Ajax レスポンス —— カートの点数バッジだけを返す

**共通部品** `view/shop/_parts/cart_badge.html`

```html
{# カートの点数バッジ。全体描画とAjaxレスポンスの両方から使う #}
<span class="c-badge">{_cartCount}</span>
```

**全体描画側** `view/shop/_parts/header.html`

```html
{# 共通ヘッダー。バッジは部品として切り出しAjaxからも使えるようにする #}
<header class="l-header">
	<a href="{_navCartUrl}">カート{part:cart_badge}</a>
</header>
```

**Ajax用の View** `view/shop/cart_badge.html`

```html
{# カートの点数バッジだけを返すAjax用のView。レイアウトなしで描画する #}
{part:_parts/cart_badge}
```

**Model**

```php
/**
 * カートの点数バッジだけを描画する関数(Ajaxレスポンス用)
 *
 * レイアウトを挟まずHTML断片だけを返す。
 * 断片の前後には改行が入るため、呼び出し側で扱いやすいよう除去しておく。
 *
 * @author S.Morikane
 * @return string  //描画結果のHTML断片
 */
public function renderCartBadge() {
	$data = $this->createViewData();

	//バッジの表示に必要な値だけを設定する
	$data->set('_cartCount', $this->countCartItems());

	//レイアウトなし(第3引数にnull)で描画する
	return trim($this->render('cart_badge', $data, null));
}
```

**Controller**

```php
/**
 * 商品をカートへ追加してバッジのHTMLだけを返す関数(Ajax用。POSTのみ)
 * @author S.Morikane
 * @return string
 */
public function addAjaxAction() {
	//POST以外でアクセスされた場合は何も返さない
	if(!$this->request->isPost()) {
		$this->response->setStatusCode(405, 'Method Not Allowed');

		return '';
	}

	//CSRFトークンを検証する
	if(!$this->checkCsrfToken(self::TOKEN_CART_ADD, $this->request->getPost('_token'))) {
		$this->response->setStatusCode(400, 'Bad Request');

		return '';
	}

	//モデルのインスタンスを生成する
	$model = $this->model('CartModel');

	//モデルへカートへの追加を指示する
	$model->addToCart(
		$this->request->getPost('product_id', 0),
		$this->request->getPost('quantity', 1)
	);

	//HTML断片として返すことを明示する
	$this->response->setHttpHeader('Content-Type', 'text/html; charset=UTF-8');

	//モデルへバッジだけの描画を指示する
	return $model->renderCartBadge();
}
```

**ルーティング**

```php
//カートへの追加(Ajax。HTML断片を返す)
'/cart/add/ajax'=>array('controller'=>'cart', 'action'=>'addAjax'),
```

**呼び出し側(JavaScript)**

```javascript
fetch(baseUrl + '/cart/add/ajax', {method: 'POST', body: formData})
	.then(function(response) { return response.text(); })
	.then(function(html) { document.querySelector('.c-badge').outerHTML = html; });
```

**描画結果**

```html
<span class="c-badge">5</span>
```

#### 7.3.3 部分更新 —— 商品一覧のグリッドだけを差し替える

**共通部品** `view/shop/product/_parts/product_grid.html`

```html
{# 商品グリッド。全体描画と部分更新の両方から使う #}
{_products:n}
<p class="c-empty">該当する商品がありません。</p>
{/_products:n}

{_products:y}
<ul class="c-grid">
	{_products:start}
	<li class="c-card">
		<a href="{_url}" class="c-card__link">
			<p class="c-card__name">{_name}</p>
			<p class="c-card__price">{_price}</p>
		</a>
	</li>
	{/_products:end}
</ul>
{/_products:y}
```

**全体描画用の View** `view/shop/product/index.html`

```html
{# 商品一覧。グリッド部分は部品にして部分更新から再利用する #}
<h1 class="c-heading">{_heading}</h1>
<div id="product-list">
	{part:_parts/product_grid}
</div>
```

**部分更新用の View** `view/shop/product/list.html`

```html
{# 商品グリッドだけを返す部分更新用のView。レイアウトなしで描画する #}
{part:_parts/product_grid}
```

**Model**

```php
/**
 * 商品グリッドだけを描画する関数(部分更新用)
 *
 * 全体描画の renderIndex() と同じ行データを組み立て、
 * レイアウトを挟まずグリッド部分だけを返す。
 *
 * @author S.Morikane
 * @param $categoryId   //絞り込むカテゴリID(0で全カテゴリ)
 * @param $page         //表示するページ番号
 * @return string|null  //描画結果のHTML断片。カテゴリが存在しない場合はnull
 */
public function renderProductList($categoryId, $page) {
	$categoryId = (int)$categoryId;

	//カテゴリの指定があるかチェックする
	if($categoryId > 0 && $this->repository('Category')->fetchById($categoryId) === false) {
		//存在しないカテゴリが指定された場合は呼び出し側で404にできるようnullを返す
		return null;
	}

	$productRepository = $this->repository('Product');

	//該当する商品の総件数からページ番号を丸める
	$totalPages = $this->calculateTotalPages($productRepository->countPublished($categoryId));
	$page = $this->normalizePage($page, $totalPages);
	$offset = ($page - 1) * self::PER_PAGE;

	//表示するページ分の商品を取得する
	$products = $productRepository->fetchPublished($categoryId, self::PER_PAGE, $offset);

	$data = $this->createViewData();

	//グリッドの描画に必要な行データだけを設定する
	$data->setRows('_products', $this->buildProductRows($products));

	//レイアウトなし(第3引数にnull)で描画する
	return trim($this->render('product/list', $data, null));
}
```

**Controller**

```php
/**
 * 商品グリッドだけを描画する関数(部分更新用。GETのみ)
 * @author S.Morikane
 * @param $params  //ルーティングパラメータ
 * @return string
 * @throws HttpNotFoundException
 */
public function listAction($params=array()) {
	$categoryId = 0;

	//カテゴリで絞り込む指定があるかチェックする
	if(isset($params['id'])) {
		$categoryId = (int)$params['id'];
	}

	//モデルのインスタンスを生成する
	$model = $this->model('ProductModel');

	//モデルへグリッドだけの描画を指示する
	$html = $model->renderProductList($categoryId, (int)$this->request->getGet('page', 1));

	//存在しないカテゴリが指定された場合は404にする
	if(is_null($html)) {
		$this->forward404();
	}

	//HTML断片として返すことを明示する
	$this->response->setHttpHeader('Content-Type', 'text/html; charset=UTF-8');

	return $html;
}
```

**ルーティング**

```php
//商品グリッドだけを返す(部分更新。?page=N でページ送り)
'/product/list'=>array('controller'=>'product', 'action'=>'list'),
//カテゴリで絞り込んだ商品グリッドだけを返す(部分更新)
'/product/list/:id'=>array('controller'=>'product', 'action'=>'list'),
```

**呼び出し側(JavaScript)**

```javascript
fetch(baseUrl + '/product/list/' + categoryId + '?page=' + page)
	.then(function(response) { return response.text(); })
	.then(function(html) { document.getElementById('product-list').innerHTML = html; });
```

**描画結果**(0件のときは同じ断片が代替文言を返す)

```html
<p class="c-empty">該当する商品がありません。</p>
```

#### 7.3.4 JSON で返す場合

HTML断片と一緒に件数などを返したい場合は、断片を組み立ててから `json_encode()` で包みます。

```php
//HTML断片として組み立ててからJSONへ包む
$this->response->setHttpHeader('Content-Type', 'application/json; charset=UTF-8');

return json_encode(
	array(
		'count'=>$count,
		'html'=>$model->renderCartBadge(),
	),
	JSON_UNESCAPED_UNICODE
);
```

```json
{"count":5,"html":"<span class=\"c-badge\">5</span>"}
```

> **エスケープの責務は変わりません。**
> 断片も `ViewData` の格納APIを通っているためHTMLエスケープ済みです。
> `json_encode()` はその文字列をJSON文字列としてエスケープするだけで、二重エスケープにはなりません。

#### 7.3.5 注意点

| 項目 | 内容 |
|---|---|
| 断片の前後の空白 | 制御タグやコメントの行が除去された跡に改行が残ります。JSONへ包む場合や属性値へ埋める場合は Model 側で `trim()` してください |
| リクエストの判別 | `Request` に Ajax 判定のメソッドはありません。**Ajax 用は専用のルート/アクションに分けてください**。全体描画と断片描画で必要な `ViewData` が異なるため、分けたほうが Model も単純になります |
| CSRF | 更新系を Ajax にする場合も CSRF トークンの検証は必須です。トークンはページ描画時に発行し、`FormData` へ含めて送ります |
| エラー応答 | 断片を返すアクションでエラーになった場合は、HTMLではなくステータスコードで伝えます(`setStatusCode()`)。`forward404()` はレイアウトなしの404ページを返します |

---

## 8. 名前空間 —— `_` の有無

| 記述 | 参照先 | 格納API |
|---|---|---|
| `{full_name}` | フォーム値 | `setForm()` |
| `{_title}` | プログラム変数 | `set()` / `setRaw()` / `setFlag()` |
| `{_name}`(ループ内) | ループ行のフィールド | `setRows()` |

**`_` の有無で名前空間が完全に分離されるため、フォーム値とプログラム変数が衝突することはありません。**

`_` 空間内の名前解決の優先順位は以下のとおりです。

```
内側のループ行 → 1つ外のループ行 → ... → 最外のループ行 → ViewData変数 → 空文字
```

フォーム値(`_` なし)は常に `setForm()` の配列のみを参照し、ループやスコープの影響を受けません。

---

## 9. 処理パイプラインとキャッシュ

### 9.1 パイプライン

```
[1] Loader        テンプレートファイル読み込み
[2] コメント除去   {# ... #} を入れ子カウントで除去   ★part展開より前
[3] part 再帰展開  各 part に [1][2] を再帰適用 / 循環検出 / 依存パス記録
[4] Lexer         置換文字をトークンへ分解(右端アンカー方式)
[5] Parser        トークン列を構文木へ / 開始終了の対応を検証
[6] Compiler      構文木からPHPコードを生成
[7] Cache         コンパイル結果と依存ファイル一覧を保存
[8] 実行          コンパイル済みPHPを include して出力を得る
```

コメント除去を part 展開より前に行うため、以下が正しく機能します。

```html
{# 一時的に無効化 {part:old_header} #}
```

### 9.2 キャッシュ

- 保存先は `cache/template/`(`Application::getTemplateCacheDir()`)
- ファイル名は `tpl_<キー>.php` と `tpl_<キー>.meta.php` の2本
- `meta` には **part も含めた依存ファイル一覧** が記録される。
  part を編集しただけでも親テンプレートが再コンパイルされる
- **デバッグモード(`true`)** … 毎リクエスト、依存ファイルの更新時刻を検証して必要なら再コンパイルする
- **本番モード(`false`)** … キャッシュがあればそのまま使う

### 9.3 キャッシュのクリア

デプロイ時は以下のいずれかを行います。

```php
//Viewクラス経由
$view->clearCache();
```

```bash
# ファイルを直接削除する
rm cache/template/tpl_*.php
```

> テンプレートを編集したのに反映されない場合は、本番モードのキャッシュを疑ってください。

---

## 10. 例外

```
RuntimeException
    └─ Framework\Template\TemplateException
            ├─ TemplateNotFoundException            テンプレート/partが見つからない
            ├─ TemplateSyntaxException              構文エラー
            └─ TemplateCircularReferenceException   partの循環参照
```

`TemplateSyntaxException` は `getTemplateFile()` / `getTemplateLine()` で発生位置を取得できます。

> `Exception` の `$file` / `$line` は protected プロパティとして予約されているため、
> 独自プロパティは `$templateFile` / `$templateLine` という名前にしています。

### 10.1 コンパイル時に検出されるエラー

| ケース | 例外 |
|---|---|
| 開始タグと終了タグの変数名が一致しない / 片方しか無い | `TemplateSyntaxException` |
| 末尾セグメントが予約語でない置換文字 | `TemplateSyntaxException` |
| 閉じられていないコメント | `TemplateSyntaxException` |
| `{part:...}` のファイルが存在しない | `TemplateNotFoundException` |
| part のパスに `..` が含まれる / テンプレートルート外を指す | `TemplateSyntaxException` |
| part が循環参照している | `TemplateCircularReferenceException`(参照経路つき) |

### 10.2 実行時の挙動

| ケース | 挙動 |
|---|---|
| フォームデータに該当キーが無い | 空文字を出力 |
| `ViewData` に該当変数が無い | 空文字を出力 |
| ループ対象が0件 | `:start`〜`:end` の内容は出力しない |

**実行時例外は原則として発生しません。**

---

## 11. よくある落とし穴

### 11.1 `set()` に配列を渡す

```php
$data->set('_products', $products);   // ← InvalidArgumentException
$data->setRows('_products', $products);   // ← 正しい
```

### 11.2 判定値に変数を使おうとする

```html
{categoryId:{_id}:selected}   <!-- 動かない -->
```

判定値は静的リテラルのみです。**Model 側で選択状態を判定して `_selected` を持たせます。**

```php
$rows[] = array(
	'_id'=>(int)$carrier['id'],
	'_name'=>$carrier['name'],
	//選択中の配送業者かどうかで selected 属性を出し分ける
	'_selected'=>((string)$carrier['id'] === $currentId),
);
```

```html
<option value="{_id}"{_selected:y} selected="selected"{/_selected:y}>{_name}</option>
```

### 11.3 ループ行のフィールドに `_` を付け忘れる

```php
$rows[] = array('id'=>1, 'name'=>'商品A');    // ← {name} はフォーム値を探しに行く
$rows[] = array('_id'=>1, '_name'=>'商品A');  // ← 正しい
```

### 11.4 `{part:/...}` の起点を勘違いする

先頭スラッシュは **テンプレートルート起点** です。Viewディレクトリ起点ではありません。

| 記述 | 解決先(Viewディレクトリが `view/shop` の場合) |
|---|---|
| `{part:_parts/header}` | 呼び出し元と同じディレクトリ。例:`templates/view/shop/_parts/header.html` |
| `{part:/_parts/header}` | `templates/_parts/header.html` |

本プロジェクトではアプリごとに `_parts/` を持たせているため、**相対指定 `{part:_parts/header}` を使います。**

### 11.5 テンプレートの更新が反映されない

本番モード(`index.php`)ではキャッシュの鮮度を検証しません。
開発時は `index_dev.php` を使うか、`cache/template/` を削除してください。
