◆ JavaScript よりは少しシンプル
◆ 作り方は基本 JavaScript に合わせた

比較一覧

次は PHP です
Node.js よりは少しシンプルに書けます
サーバを作る なんてものはなく出力すればそれがレスポンスになるわけですからね
それに PHP 自体がテンプレートエンジンでもあるので JavaScript みたいな方法を取らなくても済みます
ただ 共通の HTML のベースがあって body の一部だけをページに応じて変えるというのができないので少しテンプレート機能も自分で作ってます

逆に Node.js みたいにプログラムを実行するだけでいいのではなく apache でドキュメントルートを設定したり 組み込みの簡易サーバを使ったりとプログラムの外側で設定等必要になります
それと リクエストごとに別プロセスで実行されるので Node.js みたいにプログラム中で DB の初期化をしてると毎リクエストごとになるのでちょっと遅めです
できれば先にやっておいてリクエストごとの処理には書かないようにしたほうがパフォーマンス的に良くなります

フォルダ構造

SQLite3 はエクステンションを入れると標準で使えるものでパッケージマネージャの composer を使うところはないです

.
│ readme.md

├─app
│ │ actions.php
│ │ app.php
│ │ db.php
│ │ tpl.php
│ │
│ └─templates
│ base.php
│ edit.php
│ error.php
│ search.php
│ top.php
│ view.php

└─docroot
│ .htaccess
│ index.php

└─resources
common.css
common.js

リポジトリでも見ることができます

設定

apache で実行する場合でも 組み込みサーバ機能で実行する場合でも docroot フォルダを document root に設定します
また short_open_tag を有効にします

htaccess では常に index.php を実行するように設定します

[docroot/.htaccess]
RewriteEngine on

RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]

よくあるもののコピペです
リクエストのパスにファイルがあったらそれを返してそうでないなら index.php へのリクエストに書き換えます

静的ファイルはこれだけで勝手にレスポンスを返してくれます
この当たりは Node.js より楽です

resources 以下のファイルはこれですが vanillajs の方と一緒です
無視しても大丈夫です

[docroot/resources/common.css]
*{
box-sizing: border-box;
}

body {
margin: 0;
background: #eee;
}

#container {
width: 1000px;
background: white;
box-shadow: 0 0 3px 0 white;
min-height: 100vh;
margin: auto;
padding: 10px;
}

[docroot/resources/common.js]
console.log(1)


index.php はただのエントリポイントで app/app.php を呼び出すだけです

[docroot/index.php]
<?php

require "../app/app.php";

DB

残りは Node.js のときと同じような作りです
まず DB ですが PHP になっただけで使い方は同じようなものです

[app/db.php]
<?php

class Db
{
private $db = null;
public static $instance;

public function __construct()
{
$path = realpath(ROOT . '/../../db/data.sqlite3');
$this->db = new SQLite3($path);
}

private static function sql($sql, $params)
{
$c = 0;
return preg_replace_callback('/\\:[a-z_]+|\\?/', function ($m) use ($params, &$c) {
$key = $m[0] === '?' ? $c++ : substr($m[0], 1);
$value = $params[$key];
if (is_string($value)) {
return "'" .SQLite3::escapeString((string)$value) . "'";
} else {
return (string)$value;
}
}, $sql);
}

public function init()
{
$this->db->query('
create table if not exists item (
id text,
title text,
body text,
date text,
primary key (id)
)
');
}

public function get($id = null)
{
if ($id === null) {
$results = $this->db->query('SELECT *, date(date, "localtime") as jdate FROM item ORDER BY title ASC');
$rows = [];
while ($row = $results->fetchArray(SQLITE3_ASSOC)) {
array_push($rows, $row);
}
return $rows;
} else {
return $this->db->querySingle(self::sql('SELECT *, date(date, "localtime") as jdate FROM item WHERE id = ?', [$id]), true);
}
}

public function set($id, $values)
{
$c = $this->db->querySingle(self::sql('SELECT count(*) FROM item WHERE id = ?', [$id]));
$params = [
'id' => $id,
'title' => $values['title'],
'body' => $values['body'],
];
if ($c == 0) {
$this->db->exec(self::sql('INSERT INTO item VALUES (:id, :title, :body, CURRENT_TIMESTAMP)', $params));
} else {
$this->db->exec(self::sql('UPDATE item SET title = $title, body = $body, date = CURRENT_TIMESTAMP WHERE id = :id', $params));
}
}

public function delete($id)
{
$this->db->exec(self::sql('DELETE FROM item WHERE id = ?', [$id]));
}

public function search($text)
{
$results = $this->db->query(self::sql(
'SELECT * FROM item WHERE id like :word OR title like :word OR body like :word ORDER BY title ASC',
['word' => "%{$text}%"]
));
$rows = [];
while ($row = $results->fetchArray(SQLITE3_ASSOC)) {
array_push($rows, $row);
}
return $rows;
}
}

Db::$instance = new Db();

DB ファイルのパスは同じものです

route

各 URL のマッチ条件と処理内容ですが 記法も vanillajs に合わせてあります
詳しいことはそっちを参照してください

<?php

require_once ROOT . '/db.php';
require_once ROOT . '/tpl.php';

return [
[
'title' => 'top',
'path_condition' => [''],
'action' => function () {
$rows = Db::$instance->get();
return (new Tpl(ROOT . '/templates/top.php'))->render(['list' => $rows]);
},
],
[
'title' => 'search',
'path_condition' => ['search'],
'method_condition' => 'GET',
'action' => function () {
$query = array_key_exists('q', $_GET) ? $_GET['q'] : '';
$rows = Db::$instance->search($query);
return (new Tpl(ROOT . '/templates/search.php'))->render(['list' => $rows, 'query' => $query]);
},
],
[
'title' => 'view',
'path_condition' => ['view', '/^(?<id>.+)/'],
'action' => function ($args) {
$row = Db::$instance->get($args['captures']['id']);
if ($row) {
return (new Tpl(ROOT . '/templates/view.php'))->render(['item' => $row]);
} else {
header('HTTP/1.1 404 Not Found');
return (new Tpl(ROOT . '/templates/error.php'))->render(['message' => "{$args['captures']['id']} is not created."]);
}
},
],
[
'title' => 'edit',
'path_condition' => ['edit', '/^(?<id>.+)/'],
'method_condition' => 'GET',
'action' => function ($args) {
$row = Db::$instance->get($args['captures']['id']);
if (!$row) {
$row = ['id' => $args['captures']['id'], 'no_data' => true];
}
return (new Tpl(ROOT . '/templates/edit.php'))->render(['item' => $row]);
}
],
[
'title' => 'edit_update',
'path_condition' => ['edit', '/^(?<id>.+)/'],
'method_condition' => 'POST',
'action' => function ($args) {
$post = file_get_contents("php://input");
$obj = json_decode($post, true);
$id = $args['captures']['id'];
Db::$instance->set($id, $obj);
header('Content-Type: application/json; charset=utf-8');
return json_encode(['redirect' => "/view/{$id}"]);
}
],
[
'title' => 'delete',
'path_condition' => ['delete', '/^(?<id>.+)/'],
'method_condition' => 'POST',
'action' => function ($args) {
Db::$instance->delete($args['captures']['id']);
header('Content-Type: application/json; charset=utf-8');
return json_encode(['redirect' => '/']);
},
],
[
'title' => 'default',
'action' => function () {
header('HTTP/1.1 404 Not Found');
return (new Tpl(ROOT . '/templates/error.php'))->render([]);
},
],
];

構文が PHP になっただけでそのままですね

template

テンプレートですが

return (new Tpl(ROOT . '/templates/search.php'))->render(['list' => $rows, 'query' => $query]);

という使い方をします


テンプレートの書き方の方は JavaScript のときはプログラム感が強かったですが PHP ではテンプレートの記法をベースにしています

[app/templates/error.php]
<? usetpl(__DIR__ ."/base.php") ?>

<? setvar("title", 'error'); ?>
<? setvar("head", ''); ?>

<? section("body"); ?>
<h1>404 PageNotFound</h1>
<p><?= h($message) ?></p>
<? sectionend(); ?>

<? usetplend(); ?>

[app/templates/base.php]
<!doctype html>
<title><?= h($title) ?></title>
<script src="/resources/common.js" type="module"></script>
<link rel="stylesheet" href="/resources/common.css" />
<?= $head ?>
<div id="container">
<?= $body ?>
</div>

usetpl を使うと usetplend までは指定した tpl の中に埋め込む値を定義する状態になります

setvar では関数呼び出しで直接値を設定します
section では sectionend までの出力が設定する値となります

<? setvar("title", 'error'); ?>

は title に 「error」 という文字列を設定します

<? section("body"); ?>
<h1>404 PageNotFound</h1>
<p><?= h($message) ?></p>
<? sectionend(); ?>

は body に

<h1>404 PageNotFound</h1>
<p><?= h($message) ?></p>

を設定します

これらは base.php の

<title><?= h($title) ?></title>



<div id="container">
<?= $body ?>
</div>

の部分に埋め込まれます

その仕組みを作ってるのがこのファイルです

[app/tpl.php]
<?php

class Tpl
{
public static $tpl;
private $path;
private $data;
private $base = null;
private $current_section = null;
private $sections = [];

public function __construct($path)
{
$this->path = $path;
}

public function render($data)
{
$this->data = $data;
$current = self::$tpl;
self::$tpl = $this;
$___filepath___ = $this->path;
$___data___ = $data;
ob_start();
call_user_func(function () use ($___data___, $___filepath___) {
extract($___data___);
require $___filepath___;
});
self::$tpl = $current;
return ob_get_clean();
}

public function use($path)
{
if ($this->base) {
throw new Exception('use cannot be nested');
}
$tpl = new Tpl($path);
$this->base = $tpl;
ob_start();
}

public function useEnd()
{
if ($this->base === null) {
throw new Exception('No use file.');
}
ob_end_clean();
echo $this->base->render(array_merge($this->data, $this->sections));
$this->base = null;
$this->sections = [];
}

public function section($name)
{
if ($this->current_section) {
throw new Exception('section cannot be nested');
}
$this->current_section = $name;
ob_start();
}

public function sectionEnd()
{
$name = $this->current_section;
if ($name === null) {
throw new Exception('section not found');
}
$this->current_section = null;
$data = ob_get_clean();
$this->sections[$name] = $data;
}

public function var($name, $value)
{
$this->data[$name] = $value;
}
}

function section($name)
{
Tpl::$tpl->section($name);
}

function sectionend()
{
Tpl::$tpl->sectionEnd();
}

function usetpl($name)
{
Tpl::$tpl->use($name);
}

function usetplend()
{
Tpl::$tpl->useEnd();
}

function setvar($name, $value)
{
Tpl::$tpl->var($name, $value);
}

function h($text)
{
return htmlspecialchars($text);
}

残りのテンプレートです

top

[app/templates/top.php]
<? usetpl(__DIR__ ."/base.php") ?>

<? setvar("title", "top"); ?>

<? section("head"); ?>
<style>
</style>
<? sectionend(); ?>

<? section("body"); ?>
<h1>List</h1>
<ul>
<? foreach($list as $item): ?>
<li><a href="/view/<?= h($item['id']) ?>"><?= h($item['title']) ?></a></li>
<? endforeach ?>
</ul>
<? sectionend(); ?>

<? usetplend(); ?>

foreach を普通に使えるのが良いところですね

search

[app/templates/search.php]
<? usetpl(__DIR__ ."/base.php") ?>

<? setvar("title", h($query)); ?>

<? section("head"); ?>
<style>
</style>
<? sectionend(); ?>

<? section("body"); ?>
<h1>Search result of "<?= h($query) ?>"</h1>
<div>
<form>
<input id="search" name="q" value="<?= h($query) ?>" />
<button type="submit">Search</button>
</form>
</div>
<hr>
<ul>
<? foreach($list as $item): ?>
<li><a href="/view/<?= h($item['id']) ?>"><?= h($item['title']) ?></a></li>
<? endforeach ?>
</ul>
<? sectionend(); ?>

<? usetplend(); ?>

view

[app/templates/view.php]
<? usetpl(__DIR__ ."/base.php") ?>

<? setvar("title", h($item['title'])); ?>

<? section("head"); ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.19/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/2.10.0/github-markdown.min.css">
<style>
#title {margin: 5px 0; width: 100%;}
h1 span {font-size: 12px; color: silver;}
.btns {text-align: right;}
</style>
<? sectionend(); ?>

<? section("body"); ?>
<h1><?= h($item['title']) ?> <span>(<?= h($item['jdate']) ?>)</span></h1>
<div class="btns"><button id="edit">Edit</button></div>
<div id="body" class="markdown-body"></div>
<template id="md"><?= h($item['body']) ?></template>
<script>
document.querySelector("#body").innerHTML = marked(document.querySelector("#md").content.textContent)
document.querySelector("#edit").addEventListener("click", eve => location.href = location.href.replace("view", "edit"))
</script>
<? sectionend(); ?>

<? usetplend(); ?>

edit

[app/templates/edit.php]
<? usetpl(__DIR__ ."/base.php") ?>

<? setvar("title", 'Edit - ' . h($item['id'])); ?>

<? section("head"); ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.19/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/2.10.0/github-markdown.min.css">
<style>
#title {margin: 5px 0; width: 100%;}
#body {margin: 5px 0; width: 100%; height: 100px;resize: vertical;}
.btns {text-align: right;}
#preview {margin-top: 10px;}
</style>
<? sectionend(); ?>

<? section("body"); ?>
<div><input id="title" value="<?= h($item['title'] ?? '') ?>"></div>
<textarea id="body"><?= h($item['body'] ?? '') ?></textarea>
<div class="btns">
<button id="save">Save</button>
<? if(!($item['no_data'] ?? false)): ?>
<button id="delete">Delete</button>
<? endif ?>
</div>
<hr/>
<div id="preview" class="markdown-body"></div>
<script>
const no_data = <?= json_encode($item['no_data'] ?? false) ?><?= PHP_EOL ?>
const id = <?= json_encode($item['id']) ?><?= PHP_EOL ?>
const title = document.querySelector("#title")
const body = document.querySelector("#body")
// preview
{
let tid = null
body.addEventListener("input", eve => {
clearTimeout(tid)
tid = setTimeout(update, 400)
})
function update(){
document.querySelector("#preview").innerHTML = marked(body.value)
}
update()
}
// post
{
document.querySelector("#save").addEventListener("click", eve => {
confirm("Save?") && fetch("/edit/" + id, {method: "POST", body: JSON.stringify({title: title.value, body: body.value})})
.then(e => e.json()).then(e => location.href = e.redirect)
})
no_data || document.querySelector("#delete").addEventListener("click", eve => {
confirm("Delete?") && fetch("/delete/" + id, {method: "POST"})
.then(e => e.json()).then(e => location.href = e.redirect)
})
}
</script>
<? sectionend(); ?>

<? usetplend(); ?>

JavaScript 中に文字列を埋め込む場合は

<?= PHP_EOL ?>

を使ってます
PHP のタグが行末にあると改行コードが省略されるので JavaScript の改行がなくなってこの場合は正しく動かなくなります
半角スペースを置いておくだけでも大丈夫ですが わかりづらいので知らずに行末のスペースを削除して動かなくなるケースがあるので 明示的に改行出力してます

メインの処理

最後にエントリポイントです

[app/app.php]
<?php

define('ROOT', __DIR__);

require_once ROOT . '/db.php';
require_once ROOT . '/tpl.php';

Db::$instance->init();

$actions = require ROOT . '/actions.php';
$path_str = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$path = array_slice(explode('/', $path_str), 1);
$method = $_SERVER['REQUEST_METHOD'];

// URL と メソッドにマッチする実行内容の取得
$action = null;
$captures = [];
foreach ($actions as $v) {
if (isset($v['path_condition'])) {
$i = 0;
$passed = 0;
$captures = [];
foreach ($v['path_condition'] as $c) {
$p = $path[$i++];
// 最初と最後が / なら正規表現扱い
if (preg_match('@^/.*/$@', $c)) {
if (preg_match($c, $p, $m)) {
$captures += array_filter($m, function ($k) {
return !is_int($k);
}, ARRAY_FILTER_USE_KEY);
} else {
break;
}
} else {
if ($p !== $c) {
break;
}
}
$passed++;
}
$matched = $i === $passed;
if (!$matched) {
continue;
}
}

if (isset($v['method_condition'])) {
$cond = $v['method_condition'];
$matched = is_array($cond) ? in_array($method, $cond) : $method === $cond;
if (!$matched) {
continue;
}
}

$action = $v['action'];
break;
}

$body = $action([
'path' => $path,
'captures' => $captures,
]);

if ($body) {
echo $body;
}

サーバ作る処理がいらないので ここでするのは URL にマッチするアクションを見つけて実行して結果を出力するだけです

次からははいよいよ Framework に入ります