SVNからのミラー
This commit is contained in:
558
src/Site/Lib/Activitypub.php
Normal file
558
src/Site/Lib/Activitypub.php
Normal file
@@ -0,0 +1,558 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
use Site\Lib\Curl;
|
||||
|
||||
/**
|
||||
* ActivityPubプロトコルの実装クラス
|
||||
*
|
||||
* このクラスはActivityPubプロトコルを利用して分散型
|
||||
* ソーシャルネットワーキングを実装します。
|
||||
*/
|
||||
class ActivityPub {
|
||||
private string $domain;
|
||||
private string $actor;
|
||||
private string $actorNick;
|
||||
private string $desc;
|
||||
private string $icon;
|
||||
private array $posts = [];
|
||||
|
||||
/**
|
||||
* コンストラクタ
|
||||
*
|
||||
* @param array $posts 投稿データの配列
|
||||
*/
|
||||
public function __construct(array $posts = []) {
|
||||
$this->domain = $_SERVER['SERVER_NAME'];
|
||||
$this->actor = FEDIINFO['actor'];
|
||||
$this->actorNick = FEDIINFO['actorNick'];
|
||||
$this->desc = FEDIINFO['desc'];
|
||||
$this->icon = "https://{$this->domain}".FEDIINFO['icon'];
|
||||
$this->posts = $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityPubアクタープロフィールを受け取る
|
||||
*
|
||||
* @return string アクターオブジェクト
|
||||
* @throws \Exception 公開鍵の読み込みに失敗した場合
|
||||
*/
|
||||
public function getActor(): string {
|
||||
$pubkey = file_get_contents(FEDIINFO['pubkey']);
|
||||
if ($pubkey === false) {
|
||||
throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']);
|
||||
}
|
||||
|
||||
$actor = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/actor",
|
||||
'name' => $this->actorNick,
|
||||
'summary' => $this->desc,
|
||||
'manuallyApprovesFollowers' => false,
|
||||
'icon' => [
|
||||
'type' => 'Image',
|
||||
'mediaType' => 'image/png',
|
||||
'url' => $this->icon,
|
||||
],
|
||||
'image' => [
|
||||
'type' => 'Image',
|
||||
'url' =>
|
||||
"https://{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
|
||||
'mediaType' => 'image/jpeg',
|
||||
],
|
||||
'type' => 'Person',
|
||||
'url' => "https://{$this->domain}",
|
||||
'preferredUsername' => $this->actor,
|
||||
'inbox' => "https://{$this->domain}/ap/inbox",
|
||||
'outbox' => "https://{$this->domain}/ap/outbox",
|
||||
'followers' => "https://{$this->domain}/ap/followers",
|
||||
'following' => "https://{$this->domain}/ap/following",
|
||||
'published' => '2025-03-28T18:00:00Z',
|
||||
'updated' => gmdate('c'),
|
||||
'publicKey' => [
|
||||
'id' => "https://{$this->domain}/ap/actor#main-key",
|
||||
'owner' => "https://{$this->domain}/ap/actor",
|
||||
'publicKeyPem' => $pubkey,
|
||||
],
|
||||
];
|
||||
|
||||
return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 特定のUUIDに対応するActivityを取得する
|
||||
*
|
||||
* @param string $uuid 取得するアクティビティのUUID
|
||||
* @return string JSONエンコードされたアクティビティデータ
|
||||
*/
|
||||
public function getActivity(string $uuid): string {
|
||||
$items = [];
|
||||
|
||||
foreach ($this->posts as $post) {
|
||||
if ($post['uuid'] != $uuid) continue;
|
||||
|
||||
$items = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
[
|
||||
'Emoji' => 'toot:Emoji',
|
||||
'EmojiReact' => 'litepub:EmojiReact',
|
||||
'Hashtag' => 'as:Hashtag',
|
||||
'litepub' => 'http://litepub.social/ns#',
|
||||
'sensitive' => 'as:sensitive',
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
],
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/activities/create/{$post['uuid']}",
|
||||
'type' => 'Create',
|
||||
'actor' => "https://{$this->domain}/ap/actor",
|
||||
'cc' => [
|
||||
"https://{$this->domain}/ap/followers",
|
||||
],
|
||||
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'object' => [
|
||||
'id' => "https://{$this->domain}/ap/objects/{$post['uuid']}",
|
||||
'type' => 'Note',
|
||||
'name' => $post['title'],
|
||||
'attributedTo' => "https://{$this->domain}/ap/actor",
|
||||
'cc' => [
|
||||
"https://{$this->domain}/ap/followers",
|
||||
],
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'content' =>
|
||||
$post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
|
||||
'url' => "https://{$this->domain}/blog/{$post['slug']}",
|
||||
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
|
||||
'replies' => "https://{$this->domain}/ap/objects/{$uuid}/replies",
|
||||
'sensitive' => false,
|
||||
],
|
||||
];
|
||||
|
||||
if (isset($post['category']) && !empty($post['category'])) {
|
||||
$item['tag'] = [];
|
||||
foreach ($post['category'] as $cat) {
|
||||
$items['tag'][] = $cat;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
|
||||
$imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
|
||||
$imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
|
||||
$imgraw = file_get_contents($imgpath);
|
||||
|
||||
$items['attachment'] = [
|
||||
[
|
||||
'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
|
||||
'mediaType' => mime_content_type($imgpath),
|
||||
'type' => "Image",
|
||||
'url' => $imgurl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* アウトボックスデータを取得する
|
||||
*
|
||||
* @return string JSONエンコードされたアウトボックスデータ
|
||||
*/
|
||||
public function getOutbox(): string {
|
||||
$items = [];
|
||||
$counter = 0;
|
||||
|
||||
foreach ($this->posts as $post) {
|
||||
$uid = $post['uuid'];
|
||||
|
||||
$items[] = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
[
|
||||
'Emoji' => 'toot:Emoji',
|
||||
'EmojiReact' => 'litepub:EmojiReact',
|
||||
'Hashtag' => 'as:Hashtag',
|
||||
'litepub' => 'http://litepub.social/ns#',
|
||||
'sensitive' => 'as:sensitive',
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
],
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/activities/create/{$uid}",
|
||||
'type' => 'Create',
|
||||
'actor' => "https://{$this->domain}/ap/actor",
|
||||
'cc' => [
|
||||
"https://{$this->domain}/ap/followers",
|
||||
],
|
||||
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'object' => [
|
||||
'id' => "https://{$this->domain}/ap/objects/{$uid}",
|
||||
'type' => 'Note',
|
||||
'name' => $post['title'],
|
||||
'attributedTo' => "https://{$this->domain}/ap/actor",
|
||||
'cc' => [
|
||||
"https://{$this->domain}/ap/followers",
|
||||
],
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'content' =>
|
||||
$post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
|
||||
'url' => "https://{$this->domain}/blog/{$post['slug']}",
|
||||
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
|
||||
'replies' => "https://{$this->domain}/ap/objects/{$uid}/replies",
|
||||
'sensitive' => false,
|
||||
],
|
||||
];
|
||||
|
||||
if (isset($post['category']) && !empty($post['category'])) {
|
||||
$items[$counter]['tag'] = [];
|
||||
foreach ($post['category'] as $cat) {
|
||||
$items[$counter]['tag'][] = $cat;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
|
||||
$imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
|
||||
$imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
|
||||
$imgraw = file_get_contents($imgpath);
|
||||
|
||||
$items[$counter]['attachment'] = [
|
||||
[
|
||||
'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
|
||||
'mediaType' => mime_content_type($imgpath),
|
||||
'type' => "Image",
|
||||
'url' => $imgurl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$outbox = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/outbox",
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => count($items),
|
||||
'orderedItems' => $items,
|
||||
];
|
||||
|
||||
return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebFingerデータを取得する
|
||||
*
|
||||
* @return string JSONエンコードされたWebFingerデータ
|
||||
*/
|
||||
public function getWebfinger(): string {
|
||||
$webfinger = [
|
||||
'subject' => "acct:{$this->actor}@{$this->domain}",
|
||||
'links' => [
|
||||
[
|
||||
'rel' => 'self',
|
||||
'type' => 'application/activity+json',
|
||||
'href' => "https://{$this->domain}/ap/actor",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* フォロワーのリストを取得する
|
||||
*
|
||||
* @return string JSONエンコードされたフォロワーのリスト
|
||||
*/
|
||||
public function getFollowers(): string {
|
||||
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
|
||||
|
||||
$followers = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/followers",
|
||||
'type' => 'OrderredCollection',
|
||||
'totalItems' => count($f),
|
||||
'orderedItems' => $f,
|
||||
];
|
||||
|
||||
return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* フォローしているアカウントのリストを取得する
|
||||
*
|
||||
* @return string JSONエンコードされたフォローリスト
|
||||
*/
|
||||
public function getFollowing(): string {
|
||||
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt')));
|
||||
|
||||
$following = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/following",
|
||||
'type' => 'OrderredCollection',
|
||||
'totalItems' => count($f),
|
||||
'orderedItems' => $f,
|
||||
];
|
||||
|
||||
return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* インボックスにアクティビティを投稿する
|
||||
*
|
||||
* @param array $activity 処理するアクティビティデータ
|
||||
* @return void
|
||||
*/
|
||||
public function postInbox(array $activity): void {
|
||||
switch ($activity['type']) {
|
||||
case 'Follow':
|
||||
$this->acceptFollower($activity);
|
||||
break;
|
||||
default:
|
||||
header('HTTP/1.1 501 Not Implemented');
|
||||
header('Content-Type: application/activity+json');
|
||||
echo json_encode(['error' =>
|
||||
'未対応なアクティビティタイプ: '.$activity['type']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('HTTP/1.1 200 OK');
|
||||
header('Content-Type: application/activity+json');
|
||||
echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* アクタープロフィールの更新アクティビティを作成する
|
||||
*
|
||||
* @return string JSONエンコードされた更新アクティビティ
|
||||
*/
|
||||
public function update(): string {
|
||||
$update = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/activities/update/".uuid(),
|
||||
'type' => 'Update',
|
||||
'actor' => "https://{$this->domain}/ap/actor",
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'object' => json_decode($this->getActor(), true),
|
||||
];
|
||||
|
||||
return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* アクター更新をフォロワーに送信する
|
||||
*
|
||||
* @param array $params パラメータ配列
|
||||
* @return void
|
||||
*/
|
||||
public function sendActorUpdate(array $params): void {
|
||||
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
|
||||
$ap = new Activitypub();
|
||||
$inboxes = implode("\n", $f);
|
||||
$update = json_decode($ap->update(), true);
|
||||
|
||||
foreach ($f as $inbox) {
|
||||
$this->sendActivity($inbox, $update);
|
||||
}
|
||||
}
|
||||
|
||||
// 機能性メソッド
|
||||
|
||||
/**
|
||||
* 指定されたインボックスURLにアクティビティを送信する
|
||||
*
|
||||
* @param string $inboxUrl 送信先のインボックスURL
|
||||
* @param array $activity 送信するアクティビティデータ
|
||||
* @return void
|
||||
*/
|
||||
private function sendActivity(string $inboxUrl, array $activity): void {
|
||||
$privFile = FEDIINFO['privkey'];
|
||||
$priv = file_get_contents($privFile);
|
||||
if ($priv === false) {
|
||||
logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
header('Content-Type: application/activity+json');
|
||||
echo json_encode(['error' => '秘密鍵の読込に失敗']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = json_encode($activity, JSON_UNESCAPED_SLASHES);
|
||||
$digest = base64_encode(hash('sha256', $body, true));
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$host = parse_url($inboxUrl, PHP_URL_HOST);
|
||||
|
||||
$headers = [
|
||||
'Host' => $host,
|
||||
'Date' => $date,
|
||||
'Content-Type' => 'application/activity+json',
|
||||
'Digest' => "SHA-256=$digest",
|
||||
];
|
||||
|
||||
$stringToSign = "host: {$headers['Host']}\n"
|
||||
."date: {$headers['Date']}\n"
|
||||
."digest: {$headers['Digest']}";
|
||||
logger(\LogType::ActivityPub, "署名対象: {$stringToSign}");
|
||||
|
||||
if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
|
||||
$error = openssl_error_string();
|
||||
logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}");
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
header('Content-Type: application/activity+json');
|
||||
echo json_encode(['error' => '署名に失敗']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sigValue = base64_encode($signature);
|
||||
$headers['Signature'] = "keyId=\"https://{$this->domain}/ap/actor#main-key\",";
|
||||
$headers['Signature'] .= 'algorithm="rsa-sha256",';
|
||||
$headers['Signature'] .= 'headers="host date digest",';
|
||||
$headers['Signature'] .= 'signature="'.$sigValue.'"';
|
||||
logger(\LogType::ActivityPub,
|
||||
"署名: {$headers['Signature']}\n送信データ: {$body}");
|
||||
|
||||
$curl = new Curl($inboxUrl);
|
||||
$curl->setMethod('POST')
|
||||
->setPostRaw($body)
|
||||
->setHeaders(array_map(fn($k, $v) => "$k: $v",
|
||||
array_keys($headers), $headers))
|
||||
->setCaInfo('/etc/ssl/cert.pem')
|
||||
->setVerbose(true)
|
||||
->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
|
||||
|
||||
$success = $curl->execute();
|
||||
$res = $curl->getResponseBody();
|
||||
$code = $curl->getResponseCode();
|
||||
$err = $curl->getError();
|
||||
|
||||
var_dump(print_r($res));
|
||||
logger(\LogType::ActivityPub,
|
||||
"アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
|
||||
logger(\LogType::ActivityPub, "エラー: {$err}");
|
||||
logger(\LogType::ActivityPub, "レスポンス: {$res}");
|
||||
}
|
||||
|
||||
/**
|
||||
* フォロワーを受け入れる
|
||||
*
|
||||
* @param array $activity フォローアクティビティデータ
|
||||
* @return void
|
||||
*/
|
||||
private function acceptFollower(array $activity): void {
|
||||
$followerActor = $activity['actor'] ?? null;
|
||||
if (!$followerActor) {
|
||||
header('HTTP/1.1 400 Bad Request');
|
||||
header('Content-Type: application/activity+json');
|
||||
echo json_encode(['error' => 'アクターがない']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->storeFollower($followerActor);
|
||||
|
||||
$inbox = $this->getInboxFromActor($followerActor);
|
||||
if (!$inbox) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
header('Content-Type: application/activity+json');
|
||||
echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$accept = [
|
||||
'@context' => [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
'id' => "https://{$this->domain}/ap/activities/".uniqid(),
|
||||
'type' => 'Accept',
|
||||
'actor' => "https://{$this->domain}/ap/actor",
|
||||
'object' => $activity,
|
||||
];
|
||||
|
||||
$this->sendActivity($inbox, $accept);
|
||||
}
|
||||
|
||||
/**
|
||||
* フォロワーをストレージに保存する
|
||||
*
|
||||
* @param string $followerActor フォロワーのアクターURL
|
||||
* @return void
|
||||
*/
|
||||
private function storeFollower(string $followerActor): void {
|
||||
$file = ROOT.'/data/followers.txt';
|
||||
if (!file_exists($file)) {
|
||||
touch($file);
|
||||
chmod($file, 0644);
|
||||
}
|
||||
|
||||
$followers = $this->getFollowersList();
|
||||
if (!in_array($followerActor, $followers)) {
|
||||
file_put_contents($file, "$followerActor\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* フォロワーのリストを配列として取得する
|
||||
*
|
||||
* @return array フォロワーのURLの配列
|
||||
*/
|
||||
private function getFollowersList(): array {
|
||||
$file = ROOT.'/data/followers.txt';
|
||||
$f = array_filter(explode("\n", file_get_contents($file)));
|
||||
return file_exists($file)
|
||||
? array_filter(explode("\n", file_get_contents($file))) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* アクターのインボックスURLを取得する
|
||||
*
|
||||
* @param string $actor アクターのURL
|
||||
* @return string|null インボックスURL、取得に失敗した場合はnull
|
||||
*/
|
||||
private function getInboxFromActor(string $actor): ?string {
|
||||
$curl = new Curl($actor);
|
||||
$curl->setHeaders(['Accept: application/activity+json'])
|
||||
->setFollowRedirects(true)
|
||||
->setMaxRedirects(5)
|
||||
->setCaInfo('/etc/ssl/cert.pem');
|
||||
|
||||
logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}");
|
||||
$success = $curl->execute();
|
||||
if (!$success) {
|
||||
logger(\LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError());
|
||||
return null;
|
||||
}
|
||||
|
||||
$res = $curl->getResponseBody();
|
||||
$code = $curl->getResponseCode();
|
||||
$err = $curl->getError();
|
||||
|
||||
if ($code !== 200) {
|
||||
logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($res, true);
|
||||
return $data['inbox'] ?? null;
|
||||
}
|
||||
}
|
||||
85
src/Site/Lib/Csv.php
Normal file
85
src/Site/Lib/Csv.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
enum Delimiter: int {
|
||||
case COMMA = 1;
|
||||
case SEMICOLON = 2;
|
||||
case TAB = 3;
|
||||
case PIPE = 4;
|
||||
|
||||
public static function default(): self {
|
||||
return self::COMMA;
|
||||
}
|
||||
|
||||
public function getChar(): string {
|
||||
return match ($this) {
|
||||
self::COMMA => ",",
|
||||
self::SEMICOLON => ";",
|
||||
self::TAB => "\t",
|
||||
self::PIPE => "|",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSVパーシングクラス
|
||||
*/
|
||||
class Csv {
|
||||
// リクエスト関連のプロパティ
|
||||
private bool $isHeader = false;
|
||||
private int $length = 8192;
|
||||
private Delimiter $delimiter;
|
||||
private string $filename;
|
||||
private $fp;
|
||||
|
||||
public function __construct(string $filename, int $length = 8192) {
|
||||
$this->length = $length;
|
||||
$this->filename = $filename;
|
||||
$this->delimiter = Delimiter::default();
|
||||
|
||||
$this->fp = fopen($this->filename, 'r');
|
||||
if ($this->fp === false) {
|
||||
$msg = "ファイルを開けられません。";
|
||||
logger(\LogType::Csv, $msg);
|
||||
throw new \Exception($msg);
|
||||
}
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
if ($this->fp !== false) {
|
||||
fclose($this->fp);
|
||||
}
|
||||
}
|
||||
|
||||
public function parse(?Delimiter $delimiter = null, bool $isHeader = false): array {
|
||||
$res = [];
|
||||
$this->isHeader = $isHeader;
|
||||
$this->delimiter = $delimiter ?? $this->delimiter;
|
||||
|
||||
$delimChar = $this->delimiter->getChar();
|
||||
|
||||
rewind($this->fp);
|
||||
|
||||
if ($this->isHeader) {
|
||||
$res = ['header' => [], 'body' => []];
|
||||
$head = fgets($this->fp, $this->length);
|
||||
if ($head !== false) {
|
||||
$res['header'] = str_getcsv($head, $delimChar);
|
||||
}
|
||||
}
|
||||
|
||||
while (($buffer = fgets($this->fp, $this->length)) !== false) {
|
||||
$row = str_getcsv($buffer, $delimChar);
|
||||
if ($this->isHeader) $res['body'][] = $row;
|
||||
else $res[] = $row;
|
||||
}
|
||||
|
||||
if (!feof($this->fp)) {
|
||||
$msg = "エラー:fgets()の失敗";
|
||||
logger(\LogType::Csv, $msg);
|
||||
throw new \Exception($msg);
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
691
src/Site/Lib/Curl.php
Normal file
691
src/Site/Lib/Curl.php
Normal file
@@ -0,0 +1,691 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
/**
|
||||
* php_curlへの依存を排除するための独自のCURL実装
|
||||
*/
|
||||
class Curl {
|
||||
// リクエスト関連のプロパティ
|
||||
private string $url = '';
|
||||
private string $method = 'GET';
|
||||
private int $timeout = 30;
|
||||
private array $headers = [];
|
||||
private array $cookies = [];
|
||||
private array $postFields = [];
|
||||
private string $postRaw = '';
|
||||
private string $userAgent = 'LittleBeast/1.0';
|
||||
private bool $followRedirects = true;
|
||||
private int $maxRedirects = 5;
|
||||
private bool $verbose = false;
|
||||
private $stderr = null;
|
||||
private string $caInfoPath = '';
|
||||
private bool $verifySSL = true;
|
||||
private string $username = '';
|
||||
private string $password = '';
|
||||
private string $referer = '';
|
||||
|
||||
// レスポンス関連のプロパティ
|
||||
private array $responseHeaders = [];
|
||||
private string $responseBody = '';
|
||||
private int $responseCode = 0;
|
||||
private string $responseError = '';
|
||||
private array $info = [];
|
||||
|
||||
/**
|
||||
* コンストラクタ
|
||||
*
|
||||
* @param string|null $url リクエスト先のURL
|
||||
*/
|
||||
public function __construct(?string $url = null) {
|
||||
if ($url !== null) {
|
||||
$this->url = $url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエスト先のURLを設定する
|
||||
*
|
||||
* @param string $url リクエスト先のURL
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setUrl(string $url): Curl {
|
||||
$this->url = $url;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエストメソッドを設定する
|
||||
*
|
||||
* @param string $method GE又はPOST等のHTTPメソッド
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setMethod(string $method): Curl {
|
||||
$this->method = strtoupper($method);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエストのタイムアウト秒数を設定する
|
||||
*
|
||||
* @param int $seconds タイムアウト秒数
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setTimeout(int $seconds): Curl {
|
||||
$this->timeout = (int)$seconds;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエストヘッダーを設定する
|
||||
*
|
||||
* @param array $headers リクエストヘッダーの配列
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setHeaders(array $headers): Curl {
|
||||
$this->headers = $headers;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 単一のヘッダーを追加する
|
||||
*
|
||||
* @param string $name ヘッダー名
|
||||
* @param mixed $value ヘッダー値
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function addHeader(string $name, mixed $value): Curl {
|
||||
$this->headers[$name] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエストのクッキーを設定する
|
||||
*
|
||||
* @param array $cookies クッキーの配列
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setCookies(array $cookies): Curl {
|
||||
$this->cookies = $cookies;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 単一のクッキーを追加する
|
||||
*
|
||||
* @param string $name クッキー名
|
||||
* @param mixed $value クッキー値
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function addCookie(string $name, mixed $value): Curl {
|
||||
$this->cookies[$name] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTフィールドを設定する
|
||||
*
|
||||
* @param array $fields POSTデータの配列
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setPostFields(array $fields): Curl {
|
||||
$this->postFields = $fields;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生のPOSTデータを設定する
|
||||
*
|
||||
* @param string $data 生のPOSTデータ
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setPostRaw(string $data): Curl {
|
||||
$this->postRaw = $data;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* ユーザーエージェントを設定する
|
||||
*
|
||||
* @param string $userAgent カスタムユーザーエージェント
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setUserAgent(string $userAgent): Curl {
|
||||
$this->userAgent = $userAgent;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リダイレクトを追跡するかどうかを設定する
|
||||
*
|
||||
* @param bool $follow 追跡するかどうか(デフォルトはtrue)
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setFollowRedirects(bool $follow): Curl {
|
||||
$this->followRedirects = (bool)$follow;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追跡するリダイレクトの最大数を設定する
|
||||
*
|
||||
* @param int $max リダイレクトの最大数
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setMaxRedirects(int $max): Curl {
|
||||
$this->maxRedirects = (int)$max;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSL証明書を検証するかどうかを設定する
|
||||
*
|
||||
* @param bool $verify SSL検証を行うかどうか(デフォルトはtrue)
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setVerifySSL(bool $verify): Curl {
|
||||
$this->verifySSL = (bool)$verify;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基本認証の資格情報を設定する
|
||||
*
|
||||
* @param string $username ユーザー名
|
||||
* @param string $password パスワード
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setBasicAuth(string $username, string $password): Curl {
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リファラーURLを設定する
|
||||
*
|
||||
* @param string $referer リファラーURL
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setReferer(string $referer): Curl {
|
||||
$this->referer = $referer;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 詳細ログを有効にする
|
||||
*
|
||||
* @param bool $verbose 詳細ログを有効にするかどうか
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setVerbose(bool $verbose): Curl {
|
||||
$this->verbose = (bool)$verbose;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* エラー出力先を設定する
|
||||
*
|
||||
* @param resource $handle エラー出力先のファイルハンドル
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setStderr($handle): Curl {
|
||||
$this->stderr = $handle;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSL証明書のファイルパスを設定する
|
||||
*
|
||||
* @param string $path 証明書ファイルのパス
|
||||
* @return Curl このインスタンス(メソッドチェーン用)
|
||||
*/
|
||||
public function setCaInfo(string $path): Curl {
|
||||
$this->caInfoPath = $path;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエストを実行する
|
||||
*
|
||||
* @return bool 成功または失敗
|
||||
*/
|
||||
public function execute(): bool {
|
||||
if (empty($this->url)) {
|
||||
$this->responseError = 'URLがありません';
|
||||
return false;
|
||||
}
|
||||
|
||||
// レスポンスデータのリセット
|
||||
$this->responseHeaders = [];
|
||||
$this->responseBody = '';
|
||||
$this->responseCode = 0;
|
||||
$this->responseError = '';
|
||||
$this->info = [
|
||||
'url' => $this->url,
|
||||
'content_type' => '',
|
||||
'http_code' => 0,
|
||||
'header_size' => 0,
|
||||
'request_size' => 0,
|
||||
'total_time' => 0,
|
||||
'redirect_count' => 0,
|
||||
'redirect_url' => '',
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// ソケットベースの実装を使用する
|
||||
$redirectCount = 0;
|
||||
$currentUrl = $this->url;
|
||||
$originalMethod = $this->method;
|
||||
|
||||
do {
|
||||
if ($this->verbose && $this->stderr) {
|
||||
fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
|
||||
}
|
||||
|
||||
$parsed = parse_url($currentUrl);
|
||||
if (!$parsed) {
|
||||
$this->responseError = "無効なURL: {$currentUrl}";
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http';
|
||||
$host = $parsed['host'];
|
||||
$port = isset($parsed['port'])
|
||||
? $parsed['port'] : ($scheme === 'https' ? 443 : 80);
|
||||
$path = isset($parsed['path']) ? $parsed['path'] : '/';
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
|
||||
// Basic認証
|
||||
$authHeader = '';
|
||||
if (!empty($this->username) && !empty($this->password)) {
|
||||
$authHeader = "Authorization: Basic "
|
||||
.base64_encode($this->username.':'.$this->password)."\r\n";
|
||||
} elseif (isset($parsed['user']) && isset($parsed['pass'])) {
|
||||
$authHeader = "Authorization: Basic "
|
||||
.base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n";
|
||||
}
|
||||
|
||||
// 送信するHTTPリクエストを構築
|
||||
$method = $this->method;
|
||||
$httpData = '';
|
||||
|
||||
if ($method === 'POST' || $method === 'PUT') {
|
||||
if (!empty($this->postRaw)) {
|
||||
$httpData = $this->postRaw;
|
||||
} elseif (!empty($this->postFields)) {
|
||||
$httpData = http_build_query($this->postFields);
|
||||
if (!isset($this->headers['Content-Type'])) {
|
||||
$this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
}
|
||||
$this->headers['Content-Length'] = strlen($httpData);
|
||||
}
|
||||
|
||||
// HTTPリクエストを構築
|
||||
$accept = 'Accept: */*';
|
||||
foreach ($this->headers as $h) {
|
||||
if (str_contains($h, 'Accept:')) $accept = $h;
|
||||
}
|
||||
|
||||
$request = "{$method} {$path} HTTP/1.1\r\n";
|
||||
$request .= "Host: {$host}\r\n";
|
||||
$request .= "User-Agent: {$this->userAgent}\r\n";
|
||||
$request .= "{$accept}\r\n";
|
||||
$request .= "Connection: close\r\n";
|
||||
|
||||
if (!empty($authHeader)) {
|
||||
$request .= $authHeader;
|
||||
}
|
||||
|
||||
// ヘッダーを追加
|
||||
foreach ($this->headers as $name => $value) {
|
||||
$request .= "{$name}: {$value}\r\n";
|
||||
}
|
||||
|
||||
// リファラーが設定されていれば追加
|
||||
if (!empty($this->referer) && !isset($this->headers['Referer'])) {
|
||||
$request .= "Referer: {$this->referer}\r\n";
|
||||
}
|
||||
|
||||
// クッキーヘッダーを追加
|
||||
if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
|
||||
$cookieStrings = [];
|
||||
foreach ($this->cookies as $name => $value) {
|
||||
$cookieStrings[] = $name.'='.urlencode($value);
|
||||
}
|
||||
$request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n";
|
||||
}
|
||||
|
||||
$request .= "\r\n";
|
||||
|
||||
// POSTデータを追加
|
||||
if ($method === 'POST' || $method === 'PUT') {
|
||||
$request .= $httpData;
|
||||
}
|
||||
|
||||
if ($this->verbose && $this->stderr) {
|
||||
fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n");
|
||||
}
|
||||
|
||||
// ソケット接続を確立
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
|
||||
if ($scheme === 'https') {
|
||||
$sslOptions = [
|
||||
'verify_peer' => $this->verifySSL,
|
||||
'verify_peer_name' => $this->verifySSL
|
||||
];
|
||||
|
||||
if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
|
||||
$sslOptions['cafile'] = $this->caInfoPath;
|
||||
}
|
||||
|
||||
$context = stream_context_create(['ssl' => $sslOptions]);
|
||||
|
||||
$socket = @stream_socket_client(
|
||||
"tls://{$host}:{$port}",
|
||||
$errno,
|
||||
$errstr,
|
||||
$this->timeout,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
} else {
|
||||
$socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
|
||||
}
|
||||
|
||||
if (!$socket) {
|
||||
$this->responseError = "接続出来ません: {$errstr} ({$errno})";
|
||||
if ($this->verbose && $this->stderr) {
|
||||
fwrite($this->stderr, "* エラー: {$this->responseError}\n");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// タイムアウトを設定
|
||||
stream_set_timeout($socket, $this->timeout);
|
||||
|
||||
// リクエストを送信
|
||||
fwrite($socket, $request);
|
||||
|
||||
// レスポンスを読み込む
|
||||
$rawResponse = '';
|
||||
$headers = '';
|
||||
$body = '';
|
||||
$headersComplete = false;
|
||||
|
||||
// ヘッダーとボディを分けて読み込む
|
||||
while (!feof($socket)) {
|
||||
$line = fgets($socket);
|
||||
if ($line === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$rawResponse .= $line;
|
||||
|
||||
if (!$headersComplete) {
|
||||
if (trim($line) === '') {
|
||||
$headersComplete = true;
|
||||
} else {
|
||||
$headers .= $line;
|
||||
}
|
||||
} else {
|
||||
$body .= $line;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($socket);
|
||||
|
||||
// レスポンスヘッダーを解析
|
||||
$headerLines = explode("\r\n", $headers);
|
||||
|
||||
// ステータスコードを取得
|
||||
$statusLine = isset($headerLines[0]) ? $headerLines[0] : '';
|
||||
$statusParts = explode(' ', $statusLine, 3);
|
||||
$this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0;
|
||||
$this->info['http_code'] = $this->responseCode;
|
||||
|
||||
// ヘッダーを解析
|
||||
$this->responseHeaders = [];
|
||||
$redirectUrl = '';
|
||||
|
||||
foreach ($headerLines as $index => $header) {
|
||||
if ($index === 0) continue;
|
||||
|
||||
if (strpos($header, ':') !== false) {
|
||||
list($name, $value) = explode(':', $header, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
$this->responseHeaders[$name] = $value;
|
||||
|
||||
if (strtolower($name) === 'content-type') {
|
||||
$this->info['content_type'] = $value;
|
||||
}
|
||||
|
||||
// リダイレクトをチェック
|
||||
if ($this->followRedirects &&
|
||||
strtolower($name) === 'location' &&
|
||||
$this->responseCode >= 300 &&
|
||||
$this->responseCode < 400) {
|
||||
|
||||
$redirectUrl = $value;
|
||||
|
||||
// 相対URLを絶対URLに変換
|
||||
if (strpos($redirectUrl, 'http') !== 0) {
|
||||
if ($redirectUrl[0] === '/') {
|
||||
$redirectUrl = "{$scheme}://{$host}"
|
||||
.($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl;
|
||||
} else {
|
||||
$redirectUrl = "{$scheme}://{$host}"
|
||||
.($port != 80 && $port != 443 ? ":{$port}" : '')
|
||||
.dirname($path).'/'.$redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info['redirect_url'] = $redirectUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info['header_size'] += strlen($headers);
|
||||
$this->responseBody .= $body;
|
||||
|
||||
if ($this->verbose && $this->stderr) {
|
||||
fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n");
|
||||
fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n");
|
||||
if (!empty($redirectUrl)) {
|
||||
fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n");
|
||||
}
|
||||
}
|
||||
|
||||
// リダイレクトが必要な場合
|
||||
if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) {
|
||||
$currentUrl = $redirectUrl;
|
||||
$redirectCount++;
|
||||
$this->info['redirect_count'] = $redirectCount;
|
||||
|
||||
// 302や303リダイレクトはGETにメソッドを変更
|
||||
if ($this->responseCode == 302 || $this->responseCode == 303) {
|
||||
$this->method = 'GET';
|
||||
$this->postRaw = '';
|
||||
$this->postFields = [];
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
// リクエスト完了後、元のメソッドに戻す
|
||||
$this->method = $originalMethod;
|
||||
$this->info['total_time'] = microtime(true) - $startTime;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* レスポンスボディを取得する
|
||||
*
|
||||
* @return string レスポンスボディ
|
||||
*/
|
||||
public function getResponseBody(): string {
|
||||
return $this->responseBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* レスポンスヘッダーを取得する
|
||||
*
|
||||
* @return array レスポンスヘッダーの配列
|
||||
*/
|
||||
public function getResponseHeaders(): array {
|
||||
return $this->responseHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPレスポンスコードを取得する
|
||||
*
|
||||
* @return int HTTPレスポンスコード
|
||||
*/
|
||||
public function getResponseCode(): int {
|
||||
return $this->responseCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーメッセージがあれば取得する
|
||||
*
|
||||
* @return string エラーメッセージ
|
||||
*/
|
||||
public function getError(): string {
|
||||
return $this->responseError;
|
||||
}
|
||||
|
||||
/**
|
||||
* リクエスト/レスポンス情報を取得する
|
||||
*
|
||||
* @return array 情報の配列
|
||||
*/
|
||||
public function getInfo(): array {
|
||||
return $this->info;
|
||||
}
|
||||
|
||||
// 機能性メソッド
|
||||
|
||||
/**
|
||||
* リダイレクトURLを確認する
|
||||
*
|
||||
* @param string $name ヘッダー名
|
||||
* @param string $value ヘッダー値
|
||||
* @param string $currentUrl 現在のURL
|
||||
* @return string リダイレクトURL、リダイレクトがない場合は空文字
|
||||
*/
|
||||
private function checkReds(string $name, string $value, string $currentUrl): string {
|
||||
$redirectUrl = '';
|
||||
|
||||
if ($this->followRedirects && (strtolower($name) === 'location'
|
||||
&& $this->responseCode >= 300 && $this->responseCode < 400)) {
|
||||
$redirectUrl = $value;
|
||||
|
||||
if (strpos($redirectUrl, 'http') !== 0) {
|
||||
if ($redirectUrl[0] === '/') {
|
||||
$parsed = parse_url($currentUrl);
|
||||
$redirectUrl = $parsed['scheme'].'://'.$parsed['host']
|
||||
.(isset($parsed['port']) ? ':'.$parsed['port'] : '')
|
||||
.$redirectUrl;
|
||||
} else {
|
||||
$redirectUrl = dirname($currentUrl).'/'.$redirectUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘッダー文字列を構築する
|
||||
*
|
||||
* @return string 構築されたヘッダー文字列
|
||||
*/
|
||||
private function buildHeaderString(): string {
|
||||
$headers = [];
|
||||
|
||||
// ユーザー指定のヘッダーを追加
|
||||
foreach ($this->headers as $name => $value) {
|
||||
$headers[] = "{$name}: {$value}";
|
||||
}
|
||||
|
||||
// リファラーが設定されていれば追加
|
||||
if (!empty($this->referer) && !isset($this->headers['Referer'])) {
|
||||
$headers[] = "Referer: {$this->referer}";
|
||||
}
|
||||
|
||||
// 必要に応じてクッキーヘッダーを追加
|
||||
if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
|
||||
$cookieStrings = [];
|
||||
foreach ($this->cookies as $name => $value) {
|
||||
$cookieStrings[] = $name.'='.urlencode($value);
|
||||
}
|
||||
|
||||
$headers[] = 'Cookie: '.implode('; ', $cookieStrings);
|
||||
}
|
||||
|
||||
return implode("\r\n", $headers)."\r\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* レスポンスを解析してヘッダーとボディに分割する
|
||||
*
|
||||
* @param string $response 完全なHTTPレスポンス
|
||||
* @return array [ヘッダー配列, ボディ文字列]
|
||||
*/
|
||||
private function parseResponse(string $response): array {
|
||||
$parts = explode("\r\n\r\n", $response, 2);
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return [[], ''];
|
||||
}
|
||||
|
||||
$headers = explode("\r\n", $parts[0]);
|
||||
$body = $parts[1];
|
||||
|
||||
// チャンク転送エンコーディングを処理
|
||||
if (isset($this->responseHeaders['Transfer-Encoding']) &&
|
||||
strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') {
|
||||
$body = $this->decodeChunkedBody($body);
|
||||
}
|
||||
|
||||
return [$headers, $body];
|
||||
}
|
||||
|
||||
/**
|
||||
* チャンク転送エンコーディングされたボディをデコードする
|
||||
*
|
||||
* @param string $body チャンクエンコードされたボディ
|
||||
* @return string デコードされたボディ
|
||||
*/
|
||||
private function decodeChunkedBody(string $body): string {
|
||||
$decodedBody = '';
|
||||
$position = 0;
|
||||
|
||||
while ($position < strlen($body)) {
|
||||
$lineEnd = strpos($body, "\r\n", $position);
|
||||
if ($lineEnd === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$chunkSize = hexdec(substr($body, $position, $lineEnd - $position));
|
||||
|
||||
if ($chunkSize === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$position = $lineEnd + 2;
|
||||
$decodedBody .= substr($body, $position, $chunkSize);
|
||||
$position += $chunkSize + 2; // チャンクサイズ + CRLF
|
||||
}
|
||||
|
||||
return $decodedBody;
|
||||
}
|
||||
}
|
||||
130
src/Site/Lib/DiffViewer.php
Normal file
130
src/Site/Lib/DiffViewer.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
class DiffViewer {
|
||||
private $diffContent;
|
||||
|
||||
public function __construct(string $filePath) {
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \Exception("Diff file not found: $filePath");
|
||||
}
|
||||
$this->diffContent = file_get_contents($filePath);
|
||||
}
|
||||
|
||||
public function displaySideBySide(): string {
|
||||
$lines = explode("\n", $this->diffContent);
|
||||
$fileDiffs = [];
|
||||
$currentFile = null;
|
||||
$hunk = [];
|
||||
$lineNumbers = ['left' => 0, 'right' => 0];
|
||||
$currentLeftLines = [];
|
||||
$currentRightLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// ファイルヘッダーの処理
|
||||
if (preg_match('/^---\s+(.+)/', $line, $matches)) {
|
||||
// ファイルを処理する場合、データを保存する
|
||||
if ($currentFile !== null) {
|
||||
$this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
|
||||
$fileDiffs[$currentFile] = [
|
||||
'leftLines' => $currentLeftLines,
|
||||
'rightLines' => $currentRightLines
|
||||
];
|
||||
$hunk = [];
|
||||
$currentLeftLines = [];
|
||||
$currentRightLines = [];
|
||||
$lineNumbers = ['left' => 0, 'right' => 0];
|
||||
}
|
||||
$currentFile = $matches[1];
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
|
||||
if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
|
||||
$this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
|
||||
$hunk = [];
|
||||
$lineNumbers['left'] = (int)$matches[1];
|
||||
$lineNumbers['right'] = (int)$matches[2];
|
||||
continue;
|
||||
}
|
||||
|
||||
// ハンクでの行列の集まり
|
||||
if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
|
||||
$hunk[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
// 最後のハンク・ファイルの処理
|
||||
if ($currentFile !== null) {
|
||||
$this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
|
||||
$fileDiffs[$currentFile] = [
|
||||
'leftLines' => $currentLeftLines,
|
||||
'rightLines' => $currentRightLines
|
||||
];
|
||||
}
|
||||
|
||||
// 各ファイルにHTMLの出力の作成
|
||||
$html = '';
|
||||
foreach ($fileDiffs as $fileName => $diff) {
|
||||
$html .= "<h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
|
||||
$html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
|
||||
foreach ($hunk as $line) {
|
||||
$prefix = substr($line, 0, 1);
|
||||
$content = substr($line, 1);
|
||||
|
||||
if ($prefix === '-') {
|
||||
$leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
|
||||
$lineNumbers['left']++;
|
||||
} elseif ($prefix === '+') {
|
||||
$rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
|
||||
$lineNumbers['right']++;
|
||||
} elseif ($prefix === ' ') {
|
||||
// 両側のコンテキストは同じ行列があるかの確認
|
||||
while ($lineNumbers['left'] < $lineNumbers['right']) {
|
||||
$leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
|
||||
$lineNumbers['left']++;
|
||||
}
|
||||
while ($lineNumbers['right'] < $lineNumbers['left']) {
|
||||
$rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
|
||||
$lineNumbers['right']++;
|
||||
}
|
||||
$leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
|
||||
$rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
|
||||
$lineNumbers['left']++;
|
||||
$lineNumbers['right']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateHtml(array $leftLines, array $rightLines): string {
|
||||
$html = '<table class="diff-table">';
|
||||
$html .= '<tr class="diff-header"><th colspan="2">前</th><th colspan="2">新</th></tr>';
|
||||
|
||||
$maxLines = max(count($leftLines), count($rightLines));
|
||||
for ($i = 0; $i < $maxLines; $i++) {
|
||||
$left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
|
||||
$right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
|
||||
|
||||
$html .= '<tr>';
|
||||
// 左(変更前)
|
||||
$html .= '<td class="line-number">' . ($left['line'] ?: ' ') . '</td>';
|
||||
$html .= '<td class="' . $left['type'] . '">' . ($left['content'] ?: ' ') . '</td>';
|
||||
// 右(変更後)
|
||||
$html .= '<td class="line-number">' . ($right['line'] ?: ' ') . '</td>';
|
||||
$html .= '<td class="' . $right['type'] . '">' . ($right['content'] ?: ' ') . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
238
src/Site/Lib/Mailer.php
Normal file
238
src/Site/Lib/Mailer.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
class Mailer {
|
||||
private $socket;
|
||||
private string $host;
|
||||
private int $port;
|
||||
private ?string $user;
|
||||
private ?string $pass;
|
||||
|
||||
private ?string $pgpKey;
|
||||
private ?string $pgpPass;
|
||||
|
||||
/**
|
||||
* コンストラクタ
|
||||
*/
|
||||
public function __construct() {
|
||||
if (!MAILER_ENABLED) {
|
||||
throw new \Exception("メーラーは無効です。");
|
||||
}
|
||||
|
||||
$this->host = MAILINFO['host'];
|
||||
$this->port = MAILINFO['port'];
|
||||
$this->user = MAILINFO['user'];
|
||||
$this->pass = MAILINFO['pass'];
|
||||
}
|
||||
|
||||
/**
|
||||
* ソケット接続を開く。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function connect(): void {
|
||||
$this->socket = fsockopen($this->host, $this->port, $errno, $err, 30);
|
||||
if (!$this->socket) {
|
||||
$msg = "接続に失敗: {$err} ({$errno})";
|
||||
logger(\LogType::Mailer, $msg);
|
||||
throw new \Exception($msg);
|
||||
}
|
||||
|
||||
$this->readResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* ソケット接続を切断する。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function disconnect(): void {
|
||||
$this->sendCommand('QUIT', 221);
|
||||
fclose($this->socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* サーバーで認証する。
|
||||
* ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function authenticate(): void {
|
||||
$ehloRes = $this->sendCommand('EHLO '.$this->host, 250);
|
||||
|
||||
// STARTTLSは対応するかどうか確認
|
||||
if (strpos($ehloRes, 'STARTTLS') !== false) {
|
||||
$this->sendCommand('STARTTLS', 220);
|
||||
|
||||
// TLS暗号化
|
||||
if (!stream_socket_enable_crypto(
|
||||
$this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
$msg = "TLSハンドシェイクに失敗";
|
||||
logger(\LogType::Mailer, $msg);
|
||||
throw new \Exception($msg);
|
||||
}
|
||||
|
||||
$this->sendCommand('EHLO '.gethostname(), 250);
|
||||
}
|
||||
|
||||
if ($this->user && $this->pass) {
|
||||
$this->sendCommand('AUTH LOGIN', 334);
|
||||
$this->sendCommand(base64_encode($this->user), 334);
|
||||
$this->sendCommand(base64_encode($this->pass), 235);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TLSハンドシェイクに失敗する。
|
||||
*
|
||||
* @param string $to 受信者のメールアドレス。
|
||||
* @param string $subject メールの件名。
|
||||
* @param string $body メールの本文。
|
||||
* @param string|null $toName 受信者の名前。
|
||||
* @param string|null $replyTo 返信先メールアドレス。
|
||||
* @param string|null $replyToName 返信先の名前。
|
||||
* @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function send(
|
||||
string $to,
|
||||
string $subject,
|
||||
string $body,
|
||||
?string $toName = null,
|
||||
?string $replyTo = null,
|
||||
?string $replyToName = null,
|
||||
bool $pgpSign = false
|
||||
): void {
|
||||
$from = MAILINFO['from'];
|
||||
|
||||
$this->sendCommand("MAIL FROM: <{$from}>", 250);
|
||||
$this->sendCommand("RCPT TO: <{$to}>", 250);
|
||||
$this->sendCommand('DATA', 354);
|
||||
|
||||
$headers = "Date: ".date('r')."\r\n"; // RFC 2822
|
||||
|
||||
$encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q');
|
||||
$headers .= "Subject: {$encSubject}\r\n";
|
||||
|
||||
$fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>";
|
||||
$headers .= "From: {$fromHeader}\r\n";
|
||||
|
||||
$toHeader = $toName
|
||||
? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to;
|
||||
$headers .= "To: {$toHeader}\r\n";
|
||||
|
||||
if ($replyTo) {
|
||||
$replyToHeader = $replyToName
|
||||
? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo;
|
||||
$headers .= "Reply-To: {$replyToHeader}\r\n";
|
||||
}
|
||||
|
||||
$headers .= "MIME-Version: 1.0\r\n";
|
||||
|
||||
if ($pgpSign) {
|
||||
$boundary = uniqid('BOUNDARY_');
|
||||
$headers .= "Content-Type: multipart/signed;\r\n";
|
||||
$headers .= " protocol=\"application/pgp-signature\";\r\n";
|
||||
$headers .= " micalg=php-sha512;\r\n";
|
||||
$headers .= " boundary=\"{$boundary}\"\r\n";
|
||||
} else {
|
||||
$headers .= "Content-Type: text/plain; charset=utf-8\r\n";
|
||||
$headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
|
||||
}
|
||||
|
||||
$headers .= "X-Mailer: 076 Little Beast\r\n";
|
||||
|
||||
$encBody = quoted_printable_encode($body);
|
||||
$data = $headers."\r\n".$encBody;
|
||||
|
||||
if ($pgpSign) {
|
||||
$signature = $this->signMessage($data);
|
||||
$data = "--{$boundary}\r\n"
|
||||
.$data."\r\n"
|
||||
."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"
|
||||
."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n"
|
||||
.$signature."\r\n"
|
||||
."--{$boundary}--\r\n";
|
||||
}
|
||||
|
||||
$data .= "\r\n.\r\n";
|
||||
fwrite($this->socket, $data);
|
||||
|
||||
$response = $this->readResponse();
|
||||
if (substr($response, 0, 3) != '250') {
|
||||
$msg = "メール送信に失敗: {$response}";
|
||||
logger(\LogType::Mailer, $msg);
|
||||
throw new \Exception($msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* お好みのタイムゾーンを設定する。
|
||||
* 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。
|
||||
* それも設定されていない場合、GMTタイムゾーンがデフォルトになる。
|
||||
*
|
||||
* @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo)
|
||||
* @return void
|
||||
*/
|
||||
public function setTimezone(string $zone): void {
|
||||
date_default_timezone_set($zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* PGPキーとオプションでパスフレーズを設定する。
|
||||
*
|
||||
* @param string $keypath PGP署名に使用する秘密鍵へのパス。
|
||||
* @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enablePGP(string $keypath, ?string $passphrase = null): void {
|
||||
$this->pgpKey = file_get_contents($keypath);
|
||||
$this->pgpPass = $passphrase;
|
||||
}
|
||||
|
||||
// 機能性メソッド
|
||||
|
||||
private function sendCommand(string $command, int $retcode): string {
|
||||
fwrite($this->socket, $command."\r\n");
|
||||
$res = $this->readResponse();
|
||||
if (substr($res, 0, 3) != $retcode) {
|
||||
$msg = "「{$command}」に対する予期しないレスポンス: {$res}";
|
||||
logger(\LogType::Mailer, $msg);
|
||||
throw new \Exception($msg);
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
private function readResponse(): string {
|
||||
$res = '';
|
||||
|
||||
while ($line = fgets($this->socket, 515)) {
|
||||
$res .= $line;
|
||||
if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
private function signMessage(string $message): string {
|
||||
if (extension_loaded('gnupg')) {// gnupg延長は有効の場合
|
||||
$gpg = new \gnupg();
|
||||
$gpg->addsignkey($this->pgpKey, $this->pgpPass);
|
||||
$gpg->setsignmode(\gnupg::SIG_MODE_DETACH);
|
||||
return $gpg->sign($message);
|
||||
} else { // なければ、CLIツールを使う(gnupgをインストールは必須)
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'pgpmsg');
|
||||
file_put_contents($tmp, $message);
|
||||
$sig = shell_exec(
|
||||
"gpg --batch "
|
||||
.($this->pgpPass ? "--passphrase {$this->pgpPass} " : '')
|
||||
."--detach-sign --armor {$tmp} 2>&1"
|
||||
);
|
||||
unlink($tmp);
|
||||
return $sig;
|
||||
}
|
||||
}
|
||||
}
|
||||
646
src/Site/Lib/Markdown.php
Normal file
646
src/Site/Lib/Markdown.php
Normal file
@@ -0,0 +1,646 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
class Markdown {
|
||||
private string $content;
|
||||
private array $html;
|
||||
private string $path;
|
||||
private bool $inCodeBlock = false;
|
||||
private string $codeBlockLanguage = '';
|
||||
private array $codeBlockContent = [];
|
||||
private const METADATA_LINE = "----";
|
||||
private array $algebraicPlaceholder = [];
|
||||
|
||||
public function __construct(string $path, string $section, ?string $lang = null) {
|
||||
$this->html = [];
|
||||
if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
|
||||
else $this->path = ROOT.$section.$path.'.md';
|
||||
|
||||
if (!file_exists($this->path)) {
|
||||
header('Location: /404');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* メタデータを取得する
|
||||
*
|
||||
* @return \stdClass メタデータオブジェクト
|
||||
*/
|
||||
public function getMetadata(): \stdClass {
|
||||
$content = file_get_contents($this->path);
|
||||
$metadata = new \stdClass();
|
||||
|
||||
$parts = explode(self::METADATA_LINE, $content, 2);
|
||||
if (count($parts) < 2) return $metadata;
|
||||
|
||||
$lines = explode("\n", trim($parts[0]));
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
|
||||
$colonPos = strpos($line, ':');
|
||||
if ($colonPos === false) continue;
|
||||
|
||||
$key = trim(substr($line, 0, $colonPos));
|
||||
$value = trim(substr($line, $colonPos + 1));
|
||||
$value = trim($value, '"\'');
|
||||
|
||||
if ($key == 'category' || $key == 'css') {
|
||||
$cat = explode(',', $value);
|
||||
$value = $cat;
|
||||
}
|
||||
|
||||
$metadata->$key = $value;
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdownをパースする
|
||||
*
|
||||
* @return string HTMLとしてレンダリングされたコンテンツ
|
||||
*/
|
||||
public function parse(): string {
|
||||
$content = file_get_contents($this->path);
|
||||
$parts = explode(self::METADATA_LINE, $content, 2);
|
||||
$this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
|
||||
$this->html = [];
|
||||
|
||||
$lines = explode("\n", $this->content);
|
||||
$currentParagraph = [];
|
||||
|
||||
$inList = false;
|
||||
$listItems = [];
|
||||
$listLevel = 0;
|
||||
$inBlockquote = false;
|
||||
$blockquoteContent = [];
|
||||
$tableHeaders = [];
|
||||
$tableRows = [];
|
||||
$inTable = false;
|
||||
$i = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$i++;
|
||||
$hasBR = substr($line, -1) === '\\';
|
||||
$line = rtrim($line, " \t\r\n\\");
|
||||
|
||||
// コメント
|
||||
// if (str_starts_with($line, '//')) {
|
||||
// if ($hasBR && !empty($currentParagraph)) {
|
||||
// $currentParagraph[] = '';
|
||||
// }
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// コードブロック
|
||||
if (preg_match('/^```(\w*)$/', $line, $matches)) {
|
||||
if (!$this->inCodeBlock) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
$this->inCodeBlock = true;
|
||||
$this->codeBlockLanguage = $matches[1];
|
||||
continue;
|
||||
} else {
|
||||
$this->html[] = $this->createCodeBlock();
|
||||
$this->inCodeBlock = false;
|
||||
$this->codeBlockLanguage = '';
|
||||
$this->codeBlockContent = [];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->inCodeBlock) {
|
||||
$this->codeBlockContent[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// テーブルの処理
|
||||
if (preg_match('/^\|(.+)\|$/', $line)) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
$cells = array_map('trim', explode('|', trim($line, '|')));
|
||||
|
||||
if (!$inTable) {
|
||||
$tableHeaders = $cells;
|
||||
$inTable = true;
|
||||
} elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
|
||||
continue;
|
||||
} else {
|
||||
$tableRows[] = $cells;
|
||||
}
|
||||
continue;
|
||||
} elseif ($inTable) {
|
||||
$this->html[] = $this->createTable($tableHeaders, $tableRows);
|
||||
$tableHeaders = [];
|
||||
$tableRows = [];
|
||||
$inTable = false;
|
||||
}
|
||||
|
||||
// 水平線の処理
|
||||
if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
$this->html[] = "<hr>";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 引用ブロックの処理
|
||||
if (preg_match('/^>\s(.+)/', $line, $matches)) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
$inBlockquote = true;
|
||||
$blockquoteContent[] = $this->parseInline($matches[1]);
|
||||
continue;
|
||||
} elseif ($inBlockquote && empty($line)) {
|
||||
$this->html[] = $this->createBlockquote($blockquoteContent);
|
||||
$blockquoteContent = [];
|
||||
$inBlockquote = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 空行をスキップ
|
||||
if (empty($line)) {
|
||||
if ($inList) {
|
||||
$this->html[] = $this->createList($listItems);
|
||||
$listItems = [];
|
||||
$inList = false;
|
||||
}
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// ヘッダー
|
||||
if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) {
|
||||
if ($inList) {
|
||||
$this->html[] = $this->createList($listItems);
|
||||
$listItems = [];
|
||||
$inList = false;
|
||||
}
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
|
||||
$level = strlen($m[1]);
|
||||
$this->html[] = "<h{$level}>".$this->parseInline($m[2])."</h{$level}>";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 箇条書きリスト
|
||||
if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
$inList = true;
|
||||
$currentLevel = strlen($m[1]) / 2;
|
||||
$listLevel = max($listLevel, $currentLevel);
|
||||
$listItems[] = [
|
||||
'content' => $this->parseInline($m[3]),
|
||||
'level' => $currentLevel,
|
||||
'type' => 'ul',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 番号付きリスト
|
||||
if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
$currentParagraph = [];
|
||||
}
|
||||
$inList = true;
|
||||
$currentLevel = strlen($m[1]) / 2;
|
||||
$listLevel = max($listLevel, $currentLevel);
|
||||
$listItems[] = [
|
||||
'content' => $this->parseInline($m[2]),
|
||||
'level' => $currentLevel,
|
||||
'type' => 'ol',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inList) {
|
||||
$this->html[] = $this->createList($listItems);
|
||||
$listItems = [];
|
||||
$inList = false;
|
||||
$listLevel = 0;
|
||||
}
|
||||
|
||||
$parsedLine = $this->parseInline($line);
|
||||
$currentParagraph[] = $parsedLine;
|
||||
if ($hasBR) {
|
||||
$currentParagraph[] = "<br />";
|
||||
}
|
||||
}
|
||||
|
||||
if ($inList) $this->html[] = $this->createList($listItems);
|
||||
if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent);
|
||||
if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows);
|
||||
if (!empty($currentParagraph))
|
||||
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
|
||||
|
||||
return implode("\n", $this->html);
|
||||
}
|
||||
|
||||
// 機能性メソッド
|
||||
|
||||
/**
|
||||
* インラインのMarkdown記法をパースする
|
||||
*
|
||||
* @param string $text パースするテキスト
|
||||
* @return string HTMLとしてレンダリングされたテキスト
|
||||
*/
|
||||
private function parseInline(string $text): string {
|
||||
$this->algebraicPlaceholder = [];
|
||||
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$patterns = [
|
||||
// 数式
|
||||
'/\$\$([^$]+)\$\$/u' => function($matches): string {
|
||||
$placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}';
|
||||
$this->algebraicPlaceholder[$placeholder] = $matches[1];
|
||||
return $placeholder;
|
||||
},
|
||||
// 太字
|
||||
'/\*\*(.+?)\*\*/u' => '<strong>$1</strong>',
|
||||
// 斜体
|
||||
'/\*(.+?)\*/u' => '<em>$1</em>',
|
||||
// 下線
|
||||
'/\_(.+?)\_/u' => '<u>$1</u>',
|
||||
// 取り消し線
|
||||
'/\~(.+?)\~/u' => '<s>$1</s>',
|
||||
// Blink (with speed)
|
||||
'/!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>',
|
||||
// Blink
|
||||
'/!:(.+?):!/u' => '<span class="blink">$1</span>',
|
||||
// フォントの大きさ
|
||||
'/\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>',
|
||||
// フォントカラー
|
||||
'/\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>',
|
||||
// 画像
|
||||
'/\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />',
|
||||
// 音楽
|
||||
'/\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>',
|
||||
// 動画
|
||||
'/\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>',
|
||||
// リンク
|
||||
'/\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
|
||||
// 振り仮名
|
||||
'/\<(.+?)\>\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
|
||||
// インラインコード
|
||||
'/`(.+?)`/u' => '<code>$1</code>',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $replacement) {
|
||||
if (is_callable($replacement)) {
|
||||
$text = preg_replace_callback($pattern, $replacement, $text);
|
||||
} else {
|
||||
$result = preg_replace($pattern, $replacement, $text);
|
||||
if ($result === null) continue;
|
||||
$text = $result;
|
||||
}
|
||||
}
|
||||
|
||||
// プレースホルダーの数式に交換
|
||||
foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
|
||||
$text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数式記法をパースする
|
||||
*
|
||||
* @param string $expression パースする数式
|
||||
* @return string MathJax用にフォーマットされた数式
|
||||
*/
|
||||
private function parseAlgebraic(string $expression): string {
|
||||
// HTMLエスケープ
|
||||
$expression = htmlspecialchars_decode($expression, ENT_QUOTES);
|
||||
|
||||
// ふぃりしゃもじのマッピング
|
||||
$greekLetters = [
|
||||
'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
|
||||
'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
|
||||
'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ',
|
||||
'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π',
|
||||
'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ',
|
||||
'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω',
|
||||
'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ',
|
||||
'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ',
|
||||
'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ',
|
||||
'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π',
|
||||
'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ',
|
||||
'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω',
|
||||
];
|
||||
|
||||
// ギリシャ文字を置換
|
||||
foreach ($greekLetters as $text => $symbol) {
|
||||
$expression = preg_replace("/\b$text\b/", $symbol, $expression);
|
||||
}
|
||||
|
||||
// 上付き文字(例: x^2)
|
||||
$expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) {
|
||||
$base = $matches[1];
|
||||
$exponent = $matches[2];
|
||||
if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1);
|
||||
|
||||
$superscripts = str_split($exponent);
|
||||
$supMap = [
|
||||
'0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
|
||||
'5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
|
||||
'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ',
|
||||
'i' => 'ⁱ', 'n' => 'ⁿ',
|
||||
];
|
||||
$converted = '';
|
||||
|
||||
foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
|
||||
|
||||
return $base.'<sup>'.$converted.'</sup>';
|
||||
}, $expression);
|
||||
|
||||
// 下付き文字(例: x_1)
|
||||
$expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) {
|
||||
$base = $matches[1];
|
||||
$subscript = $matches[2];
|
||||
|
||||
if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1);
|
||||
|
||||
$subscripts = str_split($subscript);
|
||||
$subMap = [
|
||||
'0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
|
||||
'5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
|
||||
];
|
||||
$converted = '';
|
||||
|
||||
foreach ($subscripts as $char) {
|
||||
$converted .= $subMap[$char] ?? $char;
|
||||
}
|
||||
|
||||
return $base.'<sub>'.$converted.'</sub>';
|
||||
}, $expression);
|
||||
|
||||
// 分数(例: 4/5x, a/b)
|
||||
$expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
|
||||
'<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>',
|
||||
$expression);
|
||||
|
||||
// 演算子
|
||||
$operators = [
|
||||
'\*' => '・',
|
||||
'!=' => '≠',
|
||||
'>=' => '≥',
|
||||
'<=' => '≤',
|
||||
'sqrt' => '√',
|
||||
];
|
||||
|
||||
foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
|
||||
|
||||
// 数式全体をspanで囲む
|
||||
return '<span class="algebraic">'.$expression.'</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* リストを作成する
|
||||
*
|
||||
* @param array $items リストアイテムの配列
|
||||
* @param int $maxLevel 最大ネストレベル
|
||||
* @return string HTMLのリスト
|
||||
*/
|
||||
private function createList(array $items, int $maxLevel = 1): string {
|
||||
if ($items === []) return '';
|
||||
|
||||
$html = '';
|
||||
$currentLevel = 0;
|
||||
$listStack = [];
|
||||
$currentType = '';
|
||||
|
||||
foreach ($items as $item) {
|
||||
$level = isset($item['level']) ? $item['level'] : $currentLevel;
|
||||
|
||||
while ($currentLevel > $level)
|
||||
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
|
||||
|
||||
while ($currentLevel < $level) {
|
||||
$currentLevel++;
|
||||
$listStack[] = $item['type'];
|
||||
$html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
|
||||
}
|
||||
|
||||
if ($currentType != $item['type'] && $currentLevel == $item['level']) {
|
||||
if (!empty($listStack)) {
|
||||
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
|
||||
$listStack[] = $item['type'];
|
||||
$html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
|
||||
}
|
||||
}
|
||||
|
||||
$currentType = $item['type'];
|
||||
}
|
||||
|
||||
$html .= "<{$currentType}>\n";
|
||||
foreach ($items as $item) {
|
||||
$html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n";
|
||||
}
|
||||
$html .= "</{$currentType}>";
|
||||
|
||||
while (!empty($listStack)) {
|
||||
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
|
||||
$currentLevel--;
|
||||
}
|
||||
|
||||
return rtrim($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* コードブロックを作成する
|
||||
*
|
||||
* @return string HTMLのコードブロック
|
||||
*/
|
||||
private function createCodeBlock(): string {
|
||||
$code = htmlspecialchars(implode("\n", $this->codeBlockContent));
|
||||
$class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : '';
|
||||
return "<pre><code{$class}>{$code}</code></pre>";
|
||||
// $raw = implode("\n", $this->codeBlockContent);
|
||||
// $lang = $this->codeBlockLanguage ?: 'txt';
|
||||
// $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
|
||||
// $highlighted = $this->highlightCode($raw, $lang);
|
||||
// return "<pre><code{$class}>{$highlighted}</code></pre>";
|
||||
}
|
||||
|
||||
private function createBlockquote(array $content): string {
|
||||
return "<blockquote>\n <p>".implode("</p>\n <p>", $content)."</p>\n</blockquote>";
|
||||
}
|
||||
|
||||
/**
|
||||
* テーブルを作成する
|
||||
*
|
||||
* @param array $headers ヘッダー配列
|
||||
* @param array $rows 行データの配列
|
||||
* @return string HTMLのテーブル
|
||||
*/
|
||||
private function createTable(array $headers, array $rows): string {
|
||||
$html = "<table>\n";
|
||||
|
||||
// ヘッダーを追加
|
||||
if (!empty($headers)) {
|
||||
$html .= " <thead>\n <tr>\n";
|
||||
foreach ($headers as $header) {
|
||||
$html .= " <th>".$this->parseInline($header)."</th>\n";
|
||||
}
|
||||
$html .= " </tr>\n </thead>\n";
|
||||
}
|
||||
|
||||
// 行を追加
|
||||
if (!empty($rows)) {
|
||||
$html .= " <tbody>\n";
|
||||
foreach ($rows as $row) {
|
||||
$html .= " <tr>\n";
|
||||
foreach ($row as $cell) {
|
||||
$html .= " <td>".$this->parseInline($cell)."</td>\n";
|
||||
}
|
||||
$html .= " </tr>\n";
|
||||
}
|
||||
$html .= " </tbody>\n";
|
||||
}
|
||||
|
||||
$html .= "</table>";
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-PHP syntax highlighter
|
||||
*
|
||||
* @param string $code Raw source code
|
||||
* @param string $lang Language identifier (html, css, js, php, …)
|
||||
* @return string HTML with <span class="hl-…"> tokens
|
||||
*/
|
||||
private function highlightCode(string $code, string $lang): string
|
||||
{
|
||||
$lang = strtolower(trim($lang));
|
||||
$rules = [];
|
||||
|
||||
// ---- COMMON -------------------------------------------------------
|
||||
$rules['comment'] = [
|
||||
// /* … */ (multi-line)
|
||||
['/\/\*(?:.|\n)*?\*\//', 'hl-comment'],
|
||||
// // …\n
|
||||
['/\/\/.*(?=\n|$)/', 'hl-comment'],
|
||||
];
|
||||
|
||||
$rules['string'] = [
|
||||
// "…" and '…' (allow escaped quotes)
|
||||
['/"(?:\\.|[^"\\\n])*"/', 'hl-string'],
|
||||
["/'(?:\\.|[^'\\\n])*'/", 'hl-string'],
|
||||
];
|
||||
|
||||
$rules['number'] = [
|
||||
['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'],
|
||||
];
|
||||
|
||||
// ---- HTML ---------------------------------------------------------
|
||||
if ($lang === 'html') {
|
||||
$rules['tag'] = [['/<\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
|
||||
$rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
|
||||
$rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
|
||||
}
|
||||
|
||||
// ---- CSS ----------------------------------------------------------
|
||||
if ($lang === 'css') {
|
||||
$rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']];
|
||||
$rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']];
|
||||
$rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']];
|
||||
$rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']];
|
||||
}
|
||||
|
||||
// ---- PHP ----------------------------------------------------------
|
||||
if ($lang === 'php') {
|
||||
$keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|'
|
||||
. 'const|continue|declare|default|die|do|echo|else|elseif|empty|'
|
||||
. 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|'
|
||||
. 'extends|final|finally|for|foreach|function|global|goto|if|'
|
||||
. 'implements|include|include_once|instanceof|insteadof|interface|'
|
||||
. 'isset|list|namespace|new|or|print|private|protected|public|'
|
||||
. 'require|require_once|return|static|switch|throw|trait|try|unset|'
|
||||
. 'use|var|while|xor|yield';
|
||||
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
|
||||
$rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']];
|
||||
$rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']];
|
||||
}
|
||||
|
||||
// ---- Shell --------------------------------------------------------
|
||||
if ($lang === 'sh') {
|
||||
$keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in';
|
||||
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
|
||||
$rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']];
|
||||
}
|
||||
|
||||
// ---- JSON ---------------------------------------------------------
|
||||
if ($lang === 'json') {
|
||||
$rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']];
|
||||
}
|
||||
|
||||
// ---- C / C++ -----------------------------------------------
|
||||
if (in_array($lang, ['c', 'cpp', 'cs'])) {
|
||||
$keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|'
|
||||
. 'continue|default|do|double|else|enum|extends|false|final|finally|'
|
||||
. 'float|for|goto|if|implements|import|instanceof|int|interface|long|'
|
||||
. 'native|new|null|package|private|protected|public|return|short|static|'
|
||||
. 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|'
|
||||
. 'try|void|volatile|while';
|
||||
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
|
||||
$rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']];
|
||||
$rules['define'] = [['/(#include)|(#define)/', 'hl-define']];
|
||||
}
|
||||
|
||||
if ($lang === 'markdown') {
|
||||
$rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']];
|
||||
$rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']];
|
||||
}
|
||||
|
||||
$html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
|
||||
|
||||
$order = ['comment', 'string', 'number'];
|
||||
foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) {
|
||||
if (isset($rules[$k])) $order[] = $k;
|
||||
}
|
||||
|
||||
foreach ($order as $type) {
|
||||
if (!isset($rules[$type])) continue;
|
||||
foreach ($rules[$type] as $rule) {
|
||||
[$pattern, $class] = $rule;
|
||||
$html = preg_replace_callback(
|
||||
$pattern,
|
||||
function ($m) use ($class) {
|
||||
// For JSON property we already built the <span>
|
||||
if (strpos($m[0], '<span') === 0) return $m[0];
|
||||
$inner = $m[0];
|
||||
// Preserve already escaped entities inside strings/comments
|
||||
return '<span class="'.$class.'">'.$inner.'</span>';
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
1559
src/Site/Lib/Mysql.php
Normal file
1559
src/Site/Lib/Mysql.php
Normal file
File diff suppressed because it is too large
Load Diff
163
src/Site/Lib/Route.php
Normal file
163
src/Site/Lib/Route.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
class Route {
|
||||
protected static array $routes = [];
|
||||
protected static array $fallback = [];
|
||||
|
||||
/**
|
||||
* ルート設定を言語固有のハンドラで初期化する
|
||||
*
|
||||
* @param array $routes ルート設定の配列
|
||||
* @return void
|
||||
*/
|
||||
public static function init(array $routes): void {
|
||||
self::$routes = $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* ルートを追加する
|
||||
*
|
||||
* @param string $method HTTPメソッド
|
||||
* @param string $path URLパス
|
||||
* @param string|callable $class ハンドラクラスとメソッド、またはコールバック
|
||||
* @param array $params オプションのパラメータ
|
||||
* @return array ルート設定
|
||||
*/
|
||||
public static function add(string $method, string $path, string|callable $class,
|
||||
array $params = []): array {
|
||||
$route = [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'class' => $class,
|
||||
'params' => $params,
|
||||
];
|
||||
|
||||
self::$routes[] = $route;
|
||||
return $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* 404処理用のフォールバックルートを設定する
|
||||
*
|
||||
* @param array|string|callable $class
|
||||
* @return void
|
||||
*/
|
||||
public static function setFallback(array|string|callable $class): void {
|
||||
self::$fallback = [
|
||||
'class' => $class,
|
||||
'params' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 適切なルートをマッチさせて実行する
|
||||
*
|
||||
* @param string $uri リクエストURI
|
||||
* @return void
|
||||
*/
|
||||
public static function dispatch(string $uri): void {
|
||||
// URIをパスとクエリ文字列に分割
|
||||
$uriParts = explode('?', $uri, 2);
|
||||
$path = trim($uriParts[0], " \t\n\r\0\x0B/");
|
||||
|
||||
// ルートパスの処理(/?page=2のようなクエリパラメータを含む場合も処理)
|
||||
if ($path === '') {
|
||||
self::executeClass([
|
||||
'class' => [new \Site\Controller\Home(), 'show'],
|
||||
'params' => ['lang' => 'ja'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($path === 'en') {
|
||||
self::executeClass([
|
||||
'class' => [new \Site\Controller\Home(), 'show'],
|
||||
'params' => ['lang' => 'en'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// パスに対してルートをマッチングする
|
||||
foreach (self::$routes as $route) {
|
||||
$matches = [];
|
||||
|
||||
if (self::matchRoute($route['path'], $path, $matches)) {
|
||||
$params = self::extractParams($route['path'], $path);
|
||||
$params = array_merge($route['params'], $params);
|
||||
|
||||
if (is_string($route['class'])) {
|
||||
[ $class, $method ] = explode('@', $route['class']);
|
||||
$controller = new $class();
|
||||
self::executeClass([
|
||||
'class' => [ $controller, $method ],
|
||||
'params' => $params,
|
||||
]);
|
||||
|
||||
return;
|
||||
} elseif (is_callable($route['class'])) {
|
||||
self::executeClass([
|
||||
'class' => $route['class'],
|
||||
'params' => $params,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// マッチするルートがない場合、フォールバックを実行
|
||||
self::executeClass(self::$fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* ルートパターンとパスをマッチングする
|
||||
*
|
||||
* @param string $pattern ルートパターン
|
||||
* @param string $path 現在のパス
|
||||
* @param array $matches マッチを格納する参照
|
||||
* @return bool
|
||||
*/
|
||||
protected static function matchRoute(string $pattern, string $path,
|
||||
array &$matches = []): bool {
|
||||
// ルートパターンを正規表現パターンに変換
|
||||
$pattern = preg_replace('/\{([^:}]+)(?::([^}]+))?\}/', '(?P<$1>[^/]+)', $pattern);
|
||||
$pattern = str_replace('/', '\/', $pattern);
|
||||
return (bool)preg_match('/^'.$pattern.'$/', $path, $matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* パターンに基づいてパスから名前付きパラメータを抽出する
|
||||
*
|
||||
* @param string $pattern ルートパターン
|
||||
* @param string $path 現在のパス
|
||||
* @return array
|
||||
*/
|
||||
protected static function extractParams(string $pattern, string $path): array {
|
||||
$params = [];
|
||||
$patternParts = explode('/', $pattern);
|
||||
$pathParts = explode('/', $path);
|
||||
|
||||
foreach ($patternParts as $k => $v) {
|
||||
if (preg_match('/\{([^:}]+)(?::([^}]+))?\}/', $v, $matches)) {
|
||||
if (isset($pathParts[$k])) {
|
||||
$params[$matches[1]] = $pathParts[$k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* ルートクラスを実行する
|
||||
*
|
||||
* @param array $route ルート設定
|
||||
* @return void
|
||||
*/
|
||||
protected static function executeClass(array $route): void {
|
||||
if (is_callable($route['class'])) {
|
||||
call_user_func($route['class'], $route['params'] ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
243
src/Site/Lib/Template.php
Normal file
243
src/Site/Lib/Template.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
class Template {
|
||||
private string $tmplExt = '.maron';
|
||||
private string $tmplPath;
|
||||
private array $vars = [];
|
||||
private array $blocks = [];
|
||||
private array $custFunc = [];
|
||||
private array $custCss = [];
|
||||
|
||||
/**
|
||||
* テンプレートクラスのコンストラクタ
|
||||
*
|
||||
* @param string $tmplPath テンプレートのパス
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(string $tmplPath) {
|
||||
$this->tmplPath = rtrim($tmplPath, '/');
|
||||
|
||||
if (substr($this->tmplPath, 0, 1) !== '/') {
|
||||
$this->tmplPath = '/'.$this->tmplPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* テンプレート変数に値を割り当てる
|
||||
*
|
||||
* @param string $name 変数名
|
||||
* @param mixed $value 値
|
||||
* @return void
|
||||
*/
|
||||
public function assign(string $name, mixed $value): void {
|
||||
$this->vars[$name] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* カスタムCSSファイルを追加する
|
||||
*
|
||||
* @param string $name CSSファイル名
|
||||
* @return void
|
||||
*/
|
||||
public function addCss(string $name): void {
|
||||
$this->custCss[] =
|
||||
'<link rel="stylesheet" type="text/css" href="/static/style-'.$name.'.css" />';
|
||||
$this->assign('custCss', $this->custCss);
|
||||
}
|
||||
|
||||
/**
|
||||
* カスタム関数を登録する
|
||||
*
|
||||
* @param string $name 関数名
|
||||
* @param callable $callback コールバック関数
|
||||
* @return void
|
||||
*/
|
||||
public function registerFunction(string $name, callable $callback): void {
|
||||
$this->custFunc[$name] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* テンプレートブロックを定義する
|
||||
*
|
||||
* @param string $name ブロック名
|
||||
* @param string $content ブロック内容
|
||||
* @return void
|
||||
*/
|
||||
public function defineBlock(string $name, string $content): void {
|
||||
if (!isset($this->blocks[$name]))
|
||||
$this->blocks[$name] = $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* テンプレートをレンダリングする
|
||||
*
|
||||
* @param string $tmplName テンプレート名
|
||||
* @return void
|
||||
*/
|
||||
public function render(string $tmplName): void {
|
||||
$tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
|
||||
if (!file_exists($tmplPath))
|
||||
throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}");
|
||||
|
||||
extract($this->vars);
|
||||
|
||||
$content = file_get_contents($tmplPath);
|
||||
|
||||
// インクルードディレクティブを処理
|
||||
while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
|
||||
$content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
|
||||
$inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt;
|
||||
if (!file_exists($inclPath))
|
||||
throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
|
||||
return file_get_contents($inclPath);
|
||||
}, $content);
|
||||
}
|
||||
|
||||
$content = $this->procDirs($content);
|
||||
$content = $this->procVars($content);
|
||||
$content = $this->procFuncs($content);
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_');
|
||||
file_put_contents($tmpFile, $content);
|
||||
|
||||
include $tmpFile;
|
||||
unlink($tmpFile);
|
||||
}
|
||||
|
||||
// 機能性メソッド
|
||||
|
||||
/**
|
||||
* テンプレートディレクティブを処理する
|
||||
*
|
||||
* @param string $content テンプレート内容
|
||||
* @return string|null 処理後の内容
|
||||
*/
|
||||
private function procDirs(string $content): string|null {
|
||||
// includeディレクティブの処理
|
||||
while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
|
||||
$content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
|
||||
$inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php';
|
||||
if (!file_exists($inclPath))
|
||||
throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
|
||||
return file_get_contents($inclPath);
|
||||
}, $content);
|
||||
}
|
||||
|
||||
$content = preg_replace('/\{@\s*if\s*\((.*?)\):\s*@\}/', '{@ if ($1) @}', $content);
|
||||
$content = preg_replace('/\{@\s*endif;\s*@\}/', '{@ endif @}', $content);
|
||||
|
||||
$processDirectives = function($c): array|string|null {
|
||||
// kysディレクティブの処理
|
||||
$c = preg_replace_callback('/\{@\s*kys\((.*?)\)\s*@\}/s', function($m): string {
|
||||
return "<?php echo '<pre>'; print_r({$m[1]}); echo '</pre>'; die(); ?>";
|
||||
}, $c);
|
||||
|
||||
// foreachループとネストした内容の処理
|
||||
$c = preg_replace_callback('/\{@\s*foreach\s*\((.*?)\)\s*@\}/s', function($m): string {
|
||||
return "<?php foreach({$m[1]}): ?>";
|
||||
}, $c);
|
||||
|
||||
$c = preg_replace_callback('/\{@\s*endforeach\s*@\}/s', function($m): string {
|
||||
return "<?php endforeach; ?>";
|
||||
}, $c);
|
||||
|
||||
// forループの処理
|
||||
$c = preg_replace_callback('/\{@\s*for\s*\((.*?)\)\s*@\}/s', function($m): string {
|
||||
return "<?php for({$m[1]}): ?>";
|
||||
}, $c);
|
||||
|
||||
$c = preg_replace_callback('/\{@\s*endfor\s*@\}/s', function($m): string {
|
||||
return "<?php endfor; ?>";
|
||||
}, $c);
|
||||
|
||||
// if-elif-else-endifの処理
|
||||
$c = preg_replace_callback('/\{@\s*if\s*\((.*?)\)\s*@\}/s', function($m): string {
|
||||
return "<?php if ({$m[1]}): ?>";
|
||||
}, $c);
|
||||
|
||||
$c = preg_replace_callback('/\{@\s*elif\s*\((.*?)\)\s*@\}/s', function($m): string {
|
||||
return "<?php elseif ({$m[1]}): ?>";
|
||||
}, $c);
|
||||
|
||||
$c = preg_replace_callback('/\{@\s*else\s*@\}/s', function($m): string {
|
||||
return "<?php else: ?>";
|
||||
}, $c);
|
||||
|
||||
$c = preg_replace_callback('/\{@\s*endif\s*@\}/s', function($m): string {
|
||||
return "<?php endif; ?>";
|
||||
}, $c);
|
||||
|
||||
return $c;
|
||||
};
|
||||
|
||||
$previousContent = '';
|
||||
$maxIterations = 10;
|
||||
$iterations = 0;
|
||||
|
||||
while ($previousContent !== $content && $iterations < $maxIterations) {
|
||||
$previousContent = $content;
|
||||
$content = $processDirectives($content);
|
||||
$iterations++;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* テンプレート変数を処理する
|
||||
*
|
||||
* @param string $content テンプレート内容
|
||||
* @return string 処理後の内容
|
||||
*/
|
||||
private function procVars(string $content): string {
|
||||
// 変数の出力(エスケープ処理なし)
|
||||
$content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string {
|
||||
return '<?= '.trim($m[1]).' ?>';
|
||||
}, $content);
|
||||
|
||||
// 変数の出力(エスケープ処理あり)
|
||||
$content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/', function($m): string {
|
||||
return '<?= htmlspecialchars('.trim($m[1]).', ENT_QUOTES, \'UTF-8\') ?>';
|
||||
}, $content);
|
||||
|
||||
// 変数の代入
|
||||
$content = preg_replace_callback('/\{\$\s*(.*?)\s*\$\}/', function($m): string {
|
||||
$parts = explode('=', $m[1], 2);
|
||||
if (count($parts) !== 2)
|
||||
throw new \RuntimeException("不正な値の形式");
|
||||
return '<?php '.trim($parts[0]).' = '.trim($parts[1]).'; ?>';
|
||||
}, $content);
|
||||
|
||||
// コメント
|
||||
$content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string {
|
||||
return '<?php /*'.trim($m[1]).'*/ ?>';
|
||||
}, $content);
|
||||
|
||||
// PHPコードの実行
|
||||
$content = preg_replace_callback('/\{\!\s*(.*?)\s*\!\}/', function($m): string {
|
||||
return '<?php '.trim($m[1]).' ?>';
|
||||
}, $content);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* カスタム関数を処理する
|
||||
*
|
||||
* @param string $content テンプレート内容
|
||||
* @return string 処理後の内容
|
||||
*/
|
||||
private function procFuncs(string $content): string {
|
||||
foreach ($this->custFunc as $name => $cb) {
|
||||
$pattern = "/\{@\s*{$name}\((.*?)\)\s*@\}/";
|
||||
$content = preg_replace_callback($pattern, function($m) use ($cb): mixed {
|
||||
$args = explode(',', $m[1]);
|
||||
$args = array_map('trim', $args);
|
||||
return call_user_func_array($cb, $args);
|
||||
}, $content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
587
src/Site/Lib/Tester.php
Normal file
587
src/Site/Lib/Tester.php
Normal file
@@ -0,0 +1,587 @@
|
||||
<?php
|
||||
namespace Site\Lib;
|
||||
|
||||
/**
|
||||
* 軽量なユニットテストフレームワーク
|
||||
*/
|
||||
class Tester {
|
||||
// テスト統計
|
||||
private int $testCount = 0;
|
||||
private int $passCount = 0;
|
||||
private int $failCount = 0;
|
||||
private int $errorCount = 0;
|
||||
|
||||
// 現在のテストケース情報
|
||||
private string $currentTestCase = '';
|
||||
private string $currentTest = '';
|
||||
|
||||
// テストスイート設定
|
||||
private bool $colorOutput = true;
|
||||
private bool $verboseOutput = true;
|
||||
private bool $stopOnFailure = false;
|
||||
private array $beforeEachCallbacks = [];
|
||||
private array $afterEachCallbacks = [];
|
||||
private array $beforeAllCallbacks = [];
|
||||
private array $afterAllCallbacks = [];
|
||||
|
||||
// ターミナル出力用の色
|
||||
private array $colors = [
|
||||
'reset' => "\033[0m",
|
||||
'red' => "\033[31m",
|
||||
'green' => "\033[32m",
|
||||
'yellow' => "\033[33m",
|
||||
'blue' => "\033[34m",
|
||||
'magenta' => "\033[35m",
|
||||
'cyan' => "\033[36m",
|
||||
'white' => "\033[37m",
|
||||
'bold' => "\033[1m",
|
||||
];
|
||||
|
||||
private array $failures = [];
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* コンストラクタ
|
||||
*
|
||||
* @param array $options 設定オプション
|
||||
*/
|
||||
public function __construct(array $options = []) {
|
||||
// オプションを設定
|
||||
if (isset($options['colorOutput'])) {
|
||||
$this->colorOutput = (bool)$options['colorOutput'];
|
||||
}
|
||||
|
||||
if (isset($options['verboseOutput'])) {
|
||||
$this->verboseOutput = (bool)$options['verboseOutput'];
|
||||
}
|
||||
|
||||
if (isset($options['stopOnFailure'])) {
|
||||
$this->stopOnFailure = (bool)$options['stopOnFailure'];
|
||||
}
|
||||
|
||||
// サポートされていない場合は色を無効にする
|
||||
if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN'
|
||||
&& !getenv('ANSICON')) {
|
||||
$this->colorOutput = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 各テストの前に実行する関数を登録する
|
||||
*
|
||||
* @param callable $callback コールバック関数
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function beforeEach(callable $callback): Tester {
|
||||
$this->beforeEachCallbacks[] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各テストの後に実行する関数を登録する
|
||||
*
|
||||
* @param callable $callback コールバック関数
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function afterEach(callable $callback): Tester {
|
||||
$this->afterEachCallbacks[] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* すべてのテストの前に実行する関数を登録する
|
||||
*
|
||||
* @param callable $callback コールバック関数
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function beforeAll(callable $callback): Tester {
|
||||
$this->beforeAllCallbacks[] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* すべてのテストの後に実行する関数を登録する
|
||||
*
|
||||
* @param callable $callback コールバック関数
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function afterAll(callable $callback): Tester {
|
||||
$this->afterAllCallbacks[] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* テストケースを定義する
|
||||
*
|
||||
* @param string $description テストケースの説明
|
||||
* @param callable $callback テストケース関数
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function describe(string $description, callable $callback): Tester {
|
||||
$this->currentTestCase = $description;
|
||||
$this->output($this->colorize('bold', "テストケース: {$description}"));
|
||||
|
||||
try {
|
||||
foreach ($this->beforeAllCallbacks as $before) {
|
||||
call_user_func($before);
|
||||
}
|
||||
|
||||
call_user_func($callback, $this);
|
||||
|
||||
foreach ($this->afterAllCallbacks as $after) {
|
||||
call_user_func($after);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->recordError(
|
||||
"テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
|
||||
$e->getTraceAsString());
|
||||
}
|
||||
|
||||
$this->output('');
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 単一のテストを実行する
|
||||
*
|
||||
* @param string $description テストの説明
|
||||
* @param callable $callback テスト関数
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function it(string $description, callable $callback): Tester {
|
||||
$this->currentTest = $description;
|
||||
$this->testCount++;
|
||||
|
||||
if ($this->verboseOutput) {
|
||||
$this->output(" ⋄ テスト中: {$description}... ", false);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($this->beforeEachCallbacks as $before) {
|
||||
call_user_func($before);
|
||||
}
|
||||
|
||||
call_user_func($callback, $this);
|
||||
|
||||
foreach ($this->afterEachCallbacks as $after) {
|
||||
call_user_func($after);
|
||||
}
|
||||
|
||||
// Test has passed.
|
||||
$this->passCount++;
|
||||
|
||||
if ($this->verboseOutput) {
|
||||
$this->output($this->colorize('green', "合格"));
|
||||
}
|
||||
} catch (AssertionFailedException $e) {
|
||||
$this->failCount++;
|
||||
|
||||
if ($this->verboseOutput) {
|
||||
$this->output($this->colorize('red', "失敗"));
|
||||
$this->output($this->colorize('red', " → ".$e->getMessage()));
|
||||
}
|
||||
|
||||
$this->recordFailure($e->getMessage());
|
||||
|
||||
if ($this->stopOnFailure) {
|
||||
$this->printSummary();
|
||||
exit(1);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->errorCount++;
|
||||
|
||||
if ($this->verboseOutput) {
|
||||
$this->output($this->colorize('yellow', "エラー"));
|
||||
$this->output($this->colorize('yellow', " → ".$e->getMessage()));
|
||||
}
|
||||
|
||||
$this->recordError($e->getMessage(), $e->getTraceAsString());
|
||||
|
||||
if ($this->stopOnFailure) {
|
||||
$this->printSummary();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* テストをスキップする
|
||||
*
|
||||
* @param string $description テストの説明
|
||||
* @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function skip(string $description,
|
||||
string $reason = 'まだ実装されていません'): Tester {
|
||||
if ($this->verboseOutput) {
|
||||
$this->output(" ⋄ スキップ: {$description}... "
|
||||
.$this->colorize('cyan', "スキップ"));
|
||||
$this->output($this->colorize('cyan', " → {$reason}"));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件がtrueである事をアサートする
|
||||
*
|
||||
* @param bool $condition チェックする条件
|
||||
* @param string $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertTrue(bool $condition,
|
||||
string $message = '条件がtrueであることを期待しました'): Tester {
|
||||
if ($condition !== true) {
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件がfalseである事をアサートする
|
||||
*
|
||||
* @param bool $condition チェックする条件
|
||||
* @param string $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertFalse(bool $condition,
|
||||
string $message = '条件がfalseであることを期待しました'): Tester {
|
||||
if ($condition !== false) {
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二つの値が等しい事をアサートする
|
||||
*
|
||||
* @param mixed $expected 期待値
|
||||
* @param mixed $actual 実際の値
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertEquals(mixed $expected, mixed $actual,
|
||||
?string $message = null): Tester {
|
||||
if ($expected != $actual) {
|
||||
if ($message === null) {
|
||||
$expected = $this->exportValue($expected);
|
||||
$actual = $this->exportValue($actual);
|
||||
$message = "{$expected}を期待しましたが、{$actual}が得られました";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二つの値が同一である事をアサートする
|
||||
*
|
||||
* @param mixed $expected 期待値
|
||||
* @param mixed $actual 実際の値
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertSame(mixed $expected, mixed $actual,
|
||||
?string $message = null): Tester {
|
||||
if ($expected !== $actual) {
|
||||
if ($message === null) {
|
||||
$expected = $this->exportValue($expected);
|
||||
$actual = $this->exportValue($actual);
|
||||
$message =
|
||||
"{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 値がnullである事をアサートする
|
||||
*
|
||||
* @param mixed $actual チェックする値
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertNull(mixed $actual, ?string $message = null): Tester {
|
||||
if ($actual !== null) {
|
||||
if ($message === null) {
|
||||
$actual = $this->exportValue($actual);
|
||||
$message = "nullを期待しましたが、{$actual}が得られました";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 値がnullでない事をアサートする
|
||||
*
|
||||
* @param mixed $actual チェックする値
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertNotNull(mixed $actual, ?string $message = null): Tester {
|
||||
if ($actual === null) {
|
||||
if ($message === null) {
|
||||
$message = "値がnullでない事を期待しました";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 値が特定のキーを持つ事をアサートする
|
||||
*
|
||||
* @param mixed $key チェックするキー
|
||||
* @param array $array チェックする配列
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertArrayHasKey(mixed $key, array $array,
|
||||
?string $message = null): Tester {
|
||||
if (!is_array($array) && !($array instanceof \ArrayAccess)) {
|
||||
throw new AssertionFailedException(
|
||||
'第2引数は配列又はArrayAccessを実装している必要があります');
|
||||
}
|
||||
|
||||
if (!array_key_exists($key, $array)) {
|
||||
if ($message === null) {
|
||||
$message = "配列がキー '{$key}' を持つ事を期待しました";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字列がサブ文字列を含むことをアサートする
|
||||
*
|
||||
* @param string $needle 検索するサブ文字列
|
||||
* @param string $haystack 検索対象の文字列
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertStringContains(string $needle, string $haystack,
|
||||
?string $message = null): Tester {
|
||||
if (!is_string($needle) || !is_string($haystack)) {
|
||||
throw new AssertionFailedException('両方の引数は文字列である必要があります');
|
||||
}
|
||||
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
if ($message === null) {
|
||||
$message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* コールバックが例外をスローする事をアサートする
|
||||
*
|
||||
* @param callable $callback 実行するコールバック
|
||||
* @param string $exceptionClass 期待される例外クラス
|
||||
* @param string|null $message 失敗時のオプションメッセージ
|
||||
* @throws AssertionFailedException アサーションが失敗した場合
|
||||
* @return Tester このインスタンス
|
||||
*/
|
||||
public function assertThrows(callable $callback, string $exceptionClass,
|
||||
?string $message = null): Tester {
|
||||
try {
|
||||
call_user_func($callback);
|
||||
|
||||
if ($message === null) {
|
||||
$message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
} catch (\Throwable $e) {
|
||||
if (!($e instanceof $exceptionClass)) {
|
||||
if ($message === null) {
|
||||
$message = "'{$exceptionClass}' 型の例外を期待しましたが、"
|
||||
.get_class($e)." が得られました";
|
||||
}
|
||||
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a summary of the test results
|
||||
*
|
||||
* @return Tester
|
||||
*/
|
||||
public function printSummary(): Tester {
|
||||
$this->output('');
|
||||
$this->output($this->colorize('bold', "テスト結果の概要:"));
|
||||
$this->output(" テスト総数: {$this->testCount}");
|
||||
$this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
|
||||
|
||||
if ($this->failCount > 0) {
|
||||
$this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
|
||||
} else {
|
||||
$this->output(" 失敗: 0");
|
||||
}
|
||||
|
||||
if ($this->errorCount > 0) {
|
||||
$this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
|
||||
} else {
|
||||
$this->output(" エラー: 0");
|
||||
}
|
||||
|
||||
$this->output('');
|
||||
|
||||
// 失敗を書き出す
|
||||
if (count($this->failures) > 0) {
|
||||
$this->output($this->colorize('bold', "失敗:"));
|
||||
|
||||
foreach ($this->failures as $i => $f) {
|
||||
$num = $i + 1;
|
||||
$this->output(" {$num}) {$f['testCase']} → {$f['test']}");
|
||||
$this->output(" ".$this->colorize('red', $f['message']));
|
||||
$this->output('');
|
||||
}
|
||||
}
|
||||
|
||||
// エラーを書き出す
|
||||
if (count($this->errors) > 0) {
|
||||
$this->output($this->colorize('bold', "エラー:"));
|
||||
|
||||
foreach ($this->errors as $i => $e) {
|
||||
$num = $i + 1;
|
||||
$this->output(" {$num}) {$e['testCase']} → {$e['test']}");
|
||||
$this->output(" ".$this->colorize('yellow', $e['message']));
|
||||
|
||||
if (isset($e['trace'])) {
|
||||
$this->output(" ".$this->colorize('yellow', "スタックトレース:"));
|
||||
$this->output(" ".$this->colorize('yellow', $e['trace']));
|
||||
}
|
||||
|
||||
$this->output('');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->failCount === 0 && $this->errorCount === 0) {
|
||||
$this->output($this->colorize('green', "全てのテストに合格しました!"));
|
||||
} else {
|
||||
$this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// 機能性メソッド
|
||||
|
||||
/**
|
||||
* コンソールにテキストを出力する
|
||||
*
|
||||
* @param string $text 出力するテキスト
|
||||
* @param bool $newline 改行を追加するかどうか
|
||||
* @return void
|
||||
*/
|
||||
private function output(string $text, bool $newline = true): void {
|
||||
echo $text.($newline ? PHP_EOL : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 有効な場合はテキストに色を適用する
|
||||
*
|
||||
* @param string $color 色名
|
||||
* @param string $text 色付けするテキスト
|
||||
* @return string
|
||||
*/
|
||||
private function colorize(string $color, string $text): string {
|
||||
if (!$this->colorOutput || !isset($this->colors[$color])) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return $this->colors[$color].$text.$this->colors['reset'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 値を表示用の文字列としてエクスポートする
|
||||
*
|
||||
* @param mixed $value エクスポートする値
|
||||
* @return string
|
||||
*/
|
||||
private function exportValue(mixed $value): string {
|
||||
if (is_null($value)) return 'null';
|
||||
if (is_bool($value)) return $value ? 'true' : 'false';
|
||||
if (is_array($value)) return 'Array('.count($value).')';
|
||||
if (is_object($value)) return get_class($value).' Object';
|
||||
|
||||
if (is_string($value)) {
|
||||
if (strlen($value) > 40) {
|
||||
return "'".substr($value, 0, 37)."...'";
|
||||
}
|
||||
|
||||
return "'{$value}'";
|
||||
}
|
||||
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* テストの失敗を記録する
|
||||
*
|
||||
* @param string $message 失敗メッセージ
|
||||
* @return void
|
||||
*/
|
||||
private function recordFailure(string $message): void {
|
||||
$this->failures[] = [
|
||||
'testCase' => $this->currentTestCase,
|
||||
'test' => $this->currentTest,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* テストのエラーを記録する
|
||||
*
|
||||
* @param string $message エラーメッセージ
|
||||
* @param string|null $trace スタックトレース
|
||||
* @return void
|
||||
*/
|
||||
private function recordError(string $message, ?string $trace = null): void {
|
||||
$this->errors[] = [
|
||||
'testCase' => $this->currentTestCase,
|
||||
'test' => $this->currentTest,
|
||||
'message' => $message,
|
||||
'trace' => $trace,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* アサーション失敗用のカスタム例外
|
||||
*/
|
||||
class AssertionFailedException extends \Exception {
|
||||
}
|
||||
Reference in New Issue
Block a user