以前からWebアクセシビリティに関する学習を続けているので実装に関する技術は色々知っているつもりですが(もちろん完璧とは思っていません)、第2章からの様々な事例と改善策を読むことで細かい気付きや情報のアップデートができました。中でも印象に残っているのは、4.4 ライティングの事例6で通常のテキストのNameプロパティを上書きすることは問題を引き起こします。特に不利益を被るのは「音声コントロール」を利用するユーザーです。
(今Macのブックアプリで見ているので165ページとあるのですが、書籍だと何ページだろう…?)のところです。
<button aria-label="サイト内サーチ">検索</button>
この例の場合、音声コントロールを使用されているユーザーさんが「検索をクリック」と発声してもクリックできないとのことです。表示と異なる情報を付けることに抵抗がありますし、ボタンにテキストがあるので僕は実装したことがないはず…と思いたいところ。アクセシビリティ対応では何かとスクリーンリーダーの話が出てきますが、第1章に書かれている通り多様なデバイス・多様な状況があるので、誰もがアクセスできるように慎重に実装方法を検討しなければと考えました。また、DOMとCSSオブジェクトモデルの情報を組み合わせて生成されるアクセシビリティオブジェクトモデルの深い理解も必要ですね。(業務中に「アクセシビリティツリーが…」とよく言っている気がします。)
第6章で「エンジニアだけ」「デザイナーだけ」のように、単一の職種だけでアクセシビリティ向上を推進していくのは難しいとお気付きでしょう。アクセシビリティはさまざまな職種・役割の協力があって向上していくものです。
(Macのブックアプリで見ると289ページ)とあるのはWeb制作に関わるみなさんに知ってほしいところです。エンジニアだけでやろうとしても上手くいかないんだ…と苦しく感じるシーンがやはりあるので、様々な職種のみなさんにアクセシビリティのことを知っていただいた上でお力を借りることができれば良いなと考えています。
これは2015年の12月に『僕はなぜ「Webアクセシビリティ」に取り組むのか』を書いていました。コロナ禍もあったし自分の性格上全く活動できていないなと恥ずかしくなるのですが、ひとまずそのままにしておきます。「HTMLできちんとマークアップすることがとても大事」というのは今も昔も変わりません。
来月には広島で開催される『第116回「WEB TOUCH MEETING」アクセシビリティSP』に参加します。視覚障害ではない当事者の方からお話を伺えるようですので、新たな視点を得るきっかけになればと思っています。
]]>まず「無料かつオープンなElastic Stack」からElasticsearchとKibanaをダウンロードしました。コマンドを実行するだけで起動できるので検証・開発には便利そうです。
Elasticsearchのインデックスにドキュメントを登録すれば自動でマッピングされる機能もありましたが、今回は先程のZOZO TECH BLOGを参考にマッピングを行いました。これをKibanaのコンソールを使用してAPIリクエストを実行します。
{
"settings": {
"analysis": {
"analyzer": {
"my_ja_analyzer": {
"type": "custom",
"char_filter": [
"icu_normalizer"
],
"tokenizer": "kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"ja_stop",
"kuromoji_number",
"kuromoji_stemmer"
]
}
}
}
},
"mappings": {
"properties": {
"model": {
"type": "keyword"
},
"workspace_id": {
"type": "integer"
},
"object_id": {
"type": "integer"
},
"url": {
"type": "keyword"
},
"title": {
"type": "text",
"analyzer": "my_ja_analyzer"
},
"content": {
"type": "text",
"analyzer": "my_ja_analyzer"
},
"excerpt": {
"type": "text",
"analyzer": "my_ja_analyzer"
},
"tags": {
"type": "keyword"
},
"published_on": {
"type": "date"
}
}
}
}
マッピング同様にKibanaのコンソールを利用してAPIリクエストをすれば良いのですが、試行錯誤するためには簡単に再登録ができる方が便利ですので、PowerCMS Xの記事を抽出してBulk APIで投入できるようにプログラムを書いてみました。「Elasticsearch-PHP」を利用しました。
プラグイン化はまだなので関数を定義していませんし決め打ちしているところがありますが、ワークスペースのビューにインデックスに登録するコンテンツを記述したJSON文字列を生成するテンプレートを書き、バルク登録時にビルドしています。今は記事の本文を突っ込むだけですが、実際のプロジェクトだと色々なモデルやカラムがあること、プロパティの定義によって検索の質も変わりそうなことから、自動化するよりも開発者がビューで細かく定義できる方が良いだろうと考えています。
{
"model": "entry",
"object_id": <mt:entryid />,
"workspace_id": <mt:workspaceid />,
"url": "<mt:entrypermalink encode_json />",
"title":"<mt:entrytitle encode_json />",
"content": "<mt:entrytext convert_breaks="auto" remove_html regex_replace="'/\n/',''" encode_json />",
"published_on": "<mt:entrypublishedon format_ts="c" />"
}
<?php
require_once '/Users/Shared/Sites/powercmsx/app/class.Prototype.php';
require_once '/Users/Shared/Sites/powercmsx/vendor/autoload.php';
use Elastic\Elasticsearch\ClientBuilder;
Dotenv\Dotenv::createImmutable(__DIR__)->load();
$app = new Prototype(['id' => 'Prototype']);
$app->logging = true;
$app->init();
$app->init_tags();
$client = ClientBuilder::create()
->setHosts(['https://localhost:9200'])
->setSSLVerification(false)
->setApiKey($_ENV['ELASTICSEARCH_API_KEY'])
->build();
$params = ['body' => []];
$counter = 0;
$targetModel = 'entry';
$objects = $app->db->model($targetModel)->load([
'rev_type' => 0,
'status' => 4,
'workspace_id' => 6,
]);
$template = $app->db->model('template')->load([
'basename' => 'elasticsearch',
'workspace_id' => 6,
])[0];
$workspace = $app->db->model('workspace')->load(6);
foreach ($objects as $object) {
$counter += 1;
$params['body'][] = [
'index' => [
'_index' => 'blog',
'_id' => "{$targetModel}_{$object->id}",
]
];
$app->ctx->stash('current_context', $targetModel);
$app->ctx->stash($targetModel, $object);
$app->ctx->stash('workspace', $workspace);
$jsonString = $app->ctx->build($template->text);
$params['body'][] = json_decode($jsonString, true);
// Every 200 documents stop and send the bulk request
if ($counter % 200 === 0) {
$responses = $client->bulk($params);
// erase the old bulk request
$params = ['body' => []];
// unset the bulk response when you are done to save memory
unset($responses);
}
}
// Send the last batch if it exists
if (!empty($params['body'])) {
$responses = $client->bulk($params);
}
これを実行してKibanaでインデックスを確認するとドキュメントが登録されていました。
MTElasticsearchresult
ブロックタグを実装し、ダイナミック・パブリッシングでURLにクエリを付けてリクエストをするとElasticsearchで検索した結果を返すようにしました。これもとりあえず動く状態を作り、必要に応じて改変していきたいと思います。検索条件を配列に書いてElasticsearchのPHPクライアント渡せば良いのですが、ここがとても奥が深そうです。
load();
class Elasticsearch extends PTPlugin
{
public function __construct()
{
parent::__construct();
}
public function elasticsearchResultBlockTag($args, $content, $ctx, &$repeat, $counter)
{
$localVars = [];
$app = $ctx->app;
if (!$counter) {
$client = ClientBuilder::create()
->setHosts(['https://localhost:9200'])
->setSSLVerification(false)
->setApiKey($_ENV['ELASTICSEARCH_API_KEY'])
->build();
$searchParams = [
'index' => 'blog',
'body' => [
'query' => [
'multi_match' => [
'query' => $args['query'],
'fields' => ['title^2', 'content'],
],
],
],
'from' => empty($args['offset']) ? 0 : $args['offset'],
'size' => empty($args['limit']) ? 10 : $args['limit'],
];
$response = $client->search($searchParams);
$hits = $response['hits']['hits'];
if (empty($hits)) {
$repeat = $ctx->false();
return;
}
if (!empty($localVars)) {
$ctx->localize($localVars);
}
$ctx->localParams = $hits;
}
if (!isset($hits)) {
$hits = $ctx->localParams;
}
$ctx->set_loop_vars($counter, $hits);
if (isset($hits[$counter])) {
$repeat = true;
$ctx->local_vars['score'] = $hits[$counter]['_score'];
$ctx->local_vars['url'] = $hits[$counter]['_source']['url'];
$ctx->local_vars['title'] = $hits[$counter]['_source']['title'];
} else {
unset($hits);
if (!empty($localVars)) {
$ctx->restore($localVars);
}
$repeat = $ctx->false();
}
return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
}
}
ビューを実行すると検索結果が表示されました。
検索対象フィールドの指定にてtitle^2
のように書くことでタイトルに重み付けができるところも好みです。
今気付いたのですが、朝日新聞社 Advent Calendar 2022の17日目に「PHPを使ってOpenSearchServiceで日本語全文検索する(Official PHP Client for OpenSearchを用いて) #AWS - Qiita」という記事があって、Kuromojiの話も出ていましたね。
あと、サイト内検索はどれぐらい使われるのだろう?と考えました。プロジェクトであれば昔からGoogle Analytics等でデータが取れているかと思います。
]]>PowerCMS X R&D WebsiteをNext.jsで作るなどして少しReactにも慣れ、改めて入力しながらプレビューできるアプリケーションを考えてみたところ、一応入力・プレビューが切り替わるエディタができました。「Movable Type Block Editorに似ている」と言われれば「はい」としか言いようがないのですが…、WordPressのGutenbergもMovable Type Block Editorもcontenteditable属性を使用している、ということですよね。
ComponentBlocksだと確かにどういう表示になるのか分かりづらく、某国立大学法人の提案依頼書でも「ユーザー体験の良いものを」と書いてあるので、この仕組みは一つの解決策なのだろうかと考えています。ただ、普段Reactは全く触らないので、これ以上作るのはなかなか難しそうです。書籍「これからはじめるReact実践入門」も購入しているのですが積み上げたままです。
]]>ひとまず難しい話は置いておき、メンテナンスや変更がしやすいプラグインを考えました。お題は先週の「PowerCMS XとGitHub ActionsでCSS・JavaScriptのCI/CDパイプラインを試作」です。記事を書いた後、Webhookを受信して処理するクラス、ファイルを配置・削除するクラス、GitHubからのWebhookペイロードを処理するクラスに分けて書き、プラグインが仕上がっています。
ここで「GitHub以外のサービスを利用するプロジェクトがあるのでは?」と頭をよぎります。少し調べてみるとGitLabでもビルドして成果物を作成することや、Webhookを利用することができるようです。そこで後日GitLab等への対応がしやすいよう、interfaceを使って実装してみました。
GitHosting interfaceの宣言は以下のようになりました。成果物のダウンロード処理に必要だけど、ホスティング先によって実装が異なるであろうものたちです。
<?php
namespace AssetRelease\webhook;
interface GitHosting
{
public function verifySignature(string $payloadBody): bool;
public function getArtifacts(array $payloadJSON): int|false;
public function getCommitHash(array $payloadJSON): string;
public function getWorkspaceByPayload(array $payloadJSON): ?object;
public function isTargetBranchJobCompleted(array $payloadJSON, int $workspaceId): bool;
}
例えばverifySignatureメソッドをGitHubクラスで実装すると以下のようになりました。
<?php
declare(strict_types=1);
namespace AssetRelease\webhook;
if (!defined('DS')) {
define('DS', DIRECTORY_SEPARATOR);
}
require_once dirname(__FILE__) . DS . '..' . DS . '..' . DS . 'interfaces' . DS . 'GitHosting.php';
use CurlHandle;
use AssetRelease\webhook\GitHosting;
use Prototype;
class GitHub implements GitHosting
{
private $app;
private $plugin;
public function __construct(Prototype $app)
{
$this->app = $app;
$this->plugin = $app->component('AssetRelease');
}
public function verifySignature(string $payloadBody): bool
{
if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE_256'])) {
return false;
}
$secret_token = $this->plugin->get_config_value('assetrelease_github_secret_token');
$hash = hash_hmac('sha256', $payloadBody, $secret_token);
return hash_equals("sha256={$hash}", $_SERVER['HTTP_X_HUB_SIGNATURE_256']);
}
そして、以下のように受信したWebhookの何らかの要素でホスティング先を判別し、インターフェースの実装をインスタンス化します。GitLabに対応する時は、GitHostingインターフェースに沿ってGitLabクラスを実装した後で以下のコードに追記を行います。
private function detectHosting(): ?string
{
if (!isset($_SERVER['HTTP_USER_AGENT'])) {
return null;
}
if (strpos($_SERVER['HTTP_USER_AGENT'], 'GitHub') === 0) {
return 'GitHub';
}
return null;
}
private function getGitHosting(Prototype $app): ?GitHosting
{
$hosting = $this->detectHosting();
if ($hosting === 'GitHub') {
return new GitHub($app);
}
return null;
}
Webhookを処理するメソッドでは、ホスティング先をどこかを気にせずverifySignatureメソッドやgetArtifactsメソッドなどを呼び出すことができるので楽です。
public function run()
{
$app = new Prototype(['id' => 'Worker']);
$app->logging = true;
$app->init();
$plugin = $app->component('AssetRelease');
$payloadBody = file_get_contents("php://input");
$this->sendResponse();
$gitHosting = $this->getGitHosting($app);
if (!$gitHosting) {
return;
}
if (!$this->skipVerifySignature) {
if (!$gitHosting->verifySignature($payloadBody)) {
$app->log([
'message' => $plugin->translate('Webhook delivery validation failed.'),
'category' => 'asset_release',
'level' => 'error',
]);
return;
}
}
$json = json_decode($payloadBody, true);
$workspace = $gitHosting->getWorkspaceByPayload($json);
if (!$workspace) {
return;
}
$jobCompleted = $gitHosting->isTargetBranchJobCompleted($json, (int) $workspace->id);
if (!$jobCompleted) {
return;
}
$artifactsId = $gitHosting->getArtifacts($json);
$commitHash = $gitHosting->getCommitHash($json);
interfaceの他に名前空間も利用しました。クラスが増えるとクラス名の衝突も考えられますが、プラグイン名で名前空間を利用しておけば安心です。
実際にプロジェクトで自作したプラグインが利用されるかどうかは別にして、自分で設定したお題を元にじっくりコードを考えてみるのは良い経験になりました。
]]>2年ほど苦しみ続けた2022年12月末、いい加減何か変えないと思い一般内科を受診したのですが、そこで処方されたのはなんと「ネキシウムカプセル20mg」、つまり胃酸を止める薬でした。これにはさすがに驚いたのですが、とりあえず飲み続けると数日で症状は軽快してきました。
それ以来、失ったであろう体力と自信を取り戻すべく、最初はステップ台の昇降運動から始め、4月からOXIGENOのレッスンに復帰、11月からはRADICAL POWERのレッスンにも参加できるようになりました。
さすがに2年調子が悪くてすっかり自信を無くしていたので、2023年6月に奈良に行った時は気分的に余裕がなかったのですが、先日社内会議とライブ参戦で2週連続東京に行き10,000歩歩いても平気という身体になってきました。リアルで開催される勉強会に行く勇気も出てきました。
まさに病は気からです。人よりかなり敏感なところ、不安感が強いところ、自律神経失調症はあるにせよ、それで体調をひどく悪くしては勿体ないと思うようになりました。もう一つ、メンタルの病を疑う時はまず胃食道逆流症のような他の病気がないことを確かめることも重要だと思いました。「適当に生きる」というのは相変わらずつかめないですが、深く考え込まないようにしたいです。
]]>昨日はlink要素でfonts.googleapis.com
を指定する方法で導入したのですが、最近リニューアルしたミツエーリンクスのコーポレートサイトを見ていると「Noto Sans Japanese」をセルフホスティングして配信されているので、僕も試してみたくなりました。配信までの作業過程を簡単に記録しておこうと思います。
「Webフォントを軽量化する!サブセット化とセルフホスティング |東京のWeb制作会社|株式会社ENVY DESIGN」を参考にしました。
まずは「Noto Sans Japanese」のバリアブルフォントをダウンロード。展開して武蔵システムの「サブセットフォントメーカー」でサブセット化を試みました。サブセット化する文字の選択は「日本語WEBフォントをサブセット化する際の参考文字列一覧 | U-618WEB」を参考にし、「JIS第1水準+常用漢字+その他でまとめると(3759字)」に列挙されている文字にしました。
その後、WOFF2にコンバートしてCSSで指定、表示を確認したのですがなぜかフォントが全て細い…。CSSの指定は間違えてなさそうだしなんだろう?、と考えたのですが、Wakamai Fondueで確認してみると全然バリアブルでないフォントになっていました。ここでサブセットフォントメーカーは使えないのか、と悟りました。
何か良いサブセット化ツールはないかと探してみたところ、「How to subset a variable font | Clagnut by Richard Rutter」で紹介されている「fonttools」が適しているようなので試してみました。
サブセット化する文字をファイルに書いて渡せないかな?と考えたのですが、ちょうど--text-file
オプションがあってテキストファイルが指定できたので助かりました。その他のオプションはひとまずブログで紹介されていたものを指定しました。
pyftsubset ./NotoSansJP-VariableFont_wght.ttf --text-file=subset.txt --layout-features="*" --flavor="woff2" --output-file="NotoSansJP-VariableFont_wght.woff2"
生成されたWOFF2ファイルを再びWakamai Fondueで確認すると、容量や収録されている文字、Layout featuresが違いますが、ほぼミツエーリンクスのコーポレートサイトと同じものができたようです。
CSSファイルに必要な記述をして表示確認したところ、今度は意図した表示になりました。現在サイズが約1.1MBなので、もう少し軽量化できないかなと考えています。最近のオフタイムはPHPを書いていることが多い気がするので、フロントエンドも頑張らなければと感じました。
]]>そこで、コーディングデータを管理するリポジトリを利用し、GitHub Actionsでワークフローを実行してCSS・JavaScriptの成果物を生成し、その成果物をPowerCMS Xで展開するCI/CDパイプラインを試作してみました。(CI/CDパイプラインと言うのは少し大げさかも、と思いつつ…)もしかすると「GitHubのワークフローで直接サーバーに配置しても良いのでは?」と思われるかもしれませんが、PowerCMS XのURLオブジェクトがないとプレビュー時にファイルが利用できない(HTTP 404エラーとなる)、AWS_S3プラグインやAWS_CloudFrontプラグインとの連携ができない、などの制限が発生するので本プラグインが必要となります。
GitHubのリポジトリ設定でWebhookを追加します。トリガーするイベントは「Workflow runs」を選択し、ワークフローが完了した際に情報を受け取ることができるようにします。
GitHub Actionsで実行するワークフローを.github/workflow/ci.yml
に記述します。今回はSassファイルからCSSを生成し、Stylelintを実行した後でdist
ディレクトリを成果物としてまとめます。ci.yml
の記述例は「ワークフロー データを成果物として保存する - GitHub Docs」で紹介されており、これを調整して利用しました。
ワークフローの実行が成功すると下記のように成果物が生成されています。
現在ローカル環境で開発しているため、ngrokを利用してWebhookを受信します。受信したWebhook本文にはワークフローの成否とコミットハッシュ、成果物情報のURL等が入っています。以下がサンプルです。(後で出てくる9:49頃のコミット分ではありませんのでご了承ください。)
{
"action": "completed",
"workflow_run": {
"id": 7827503653,
"name": "Node CI",
"node_id": "WFR_kwLOLPhz4M8AAAAB0o46JQ",
"head_branch": "main",
"head_sha": "f647348ad4798a2132bd20c876827c39de95ccaa",
"path": ".github/workflows/ci.yml",
"event": "push",
"status": "completed",
"conclusion": "success",
"workflow_id": 85323965,
"artifacts_url": "https://api.github.com/repos/hideki-a/pcmsx-asset-release-dev/actions/runs/7827503653/artifacts",
これを基に成果物のZipファイルを取得して展開します。その後、ディレクトリ内のファイルリストを取得し、URLオブジェクトを作成・更新してPTFileMgrクラスのメソッドを実行しファイルを配置します。URLオブジェクト内にMD5を保存しているので、新規ファイルと更新ファイルのみ処理されます。
試作段階のコードは以下の通りです。
<?php
declare(strict_types=1);
namespace AssetRelease;
use Prototype;
class Distributor
{
private $app;
private $workspace;
private $workDir;
public function __construct(int $artifactsId)
{
// FIXME: 汎用化する
// unset($app->hooks['take_down']); // (S3, CloudFrontを停止)
$workspace_id = 14;
$workBaseDir = '/Users/Shared/Sites/powercmsx/support/asset_release';
$app = Prototype::get_instance();
$this->app = $app;
$this->workDir = $workBaseDir . DS . $artifactsId;
$this->workspace = $app->db->model('workspace')->load($workspace_id);
}
private function scanFiles(): array
{
// NOTE: ここはディレクトリを操作してファイルをリストアップする予定
return [
'css/main.css',
];
}
private function makeFileUrl(string $filePath): string
{
$siteUrl = $this->workspace->site_url;
return $siteUrl . $filePath;
}
private function makeFileAbsolutePath(string $filePath): string
{
$sitePath = $this->workspace->site_path;
return "{$sitePath}/{$filePath}";
}
private function setUrlInfo(string $filePath, string $md5, bool $published): bool
{
$urlInfoUpdated = false;
$fileUrl = $this->makeFileUrl($filePath);
$fileAbsolutePath = $this->makeFileAbsolutePath($filePath);
$urlInfo = $this->app->db->model('urlinfo')->get_by_key([
'url' => $fileUrl,
'workspace_id' => $this->workspace->id,
]);
if (!$urlInfo->id) {
$urlInfo->dirname(preg_replace('/(.*\/).*\.\w+$/', '$1', $fileUrl));
$urlInfo->relative_url(preg_replace('/https?:\/\/[^\/]+/', '', $fileUrl));
$urlInfo->relative_path("%r/$filePath");
$urlInfo->class('archive');
$urlInfo->urlmapping_id(0);
$urlInfo->file_path($fileAbsolutePath);
$urlInfo->is_published($published);
$urlInfo->delete_flag(!$published);
$urlInfo->was_published(1);
$urlInfo->md5($md5);
$urlInfo->save();
$urlInfoUpdated = true;
} elseif ((int) $urlInfo->is_published !== (int) $published) {
$urlInfo->is_published($published);
$urlInfo->delete_flag(!$published);
$urlInfo->save();
$urlInfoUpdated = true;
} elseif ($urlInfo->md5 !== $md5) {
$urlInfo->md5($md5);
$urlInfo->save();
$urlInfoUpdated = true;
}
return $urlInfoUpdated;
}
public function run()
{
// 追加・変更されたファイルの処理
$filePaths = $this->scanFiles();
foreach ($filePaths as $filePath) {
$md5 = md5_file($this->workDir . DS . $filePath);
$urlInfoUpdated = $this->setUrlInfo($filePath, $md5, true);
$fileAbsolutePath = $this->makeFileAbsolutePath($filePath);
if ($urlInfoUpdated) {
$data = file_get_contents($this->workDir . DS . $filePath);
$this->app->fmgr->put($fileAbsolutePath, $data);
}
}
// TODO: 削除されたファイルの処理
// if ($urlInfoUpdated) {
// $this->app->fmgr->delete($fileAbsolutePath);
// }
}
}
main.scss
を更新してGitHubへのプッシュを9:49:00頃に行い、ワークフローは25秒で完了しました。まずURLオブジェクトが更新されます。
ワークフローの成果物をsupport/asset_release
ディレクトリにダウンロードして展開しました。
ワークスペースのサイト・パス内にmain.css
が配置されました。
また、AWS_S3プラグインにより同期が実行されました。
AWS_CloudFrontプラグインによりキャッシュの無効化リクエストを行うキューも生成されました。
ここまで全てGitHubへのプッシュ操作だけで完結できました。
ファイルの削除に対応しなければならないのですが、dist
ディレクトリに含まれなくなったファイルはコミットログからは分からないので、前回の成果物ディレクトリと比較してリストを抽出しようかと考えています。
本番環境はこのプラグインで処理が完結できそうですが、開発環境だと作業毎にブランチを分けることがあり、どのように成果物を配置するのが良いのだろう?と考えます。そもそも、管理画面にコピーアンドペーストする際もみなさんどのようにされているのか、お話を伺いたいところです。VercelのようにプレビューURLが発行されるとか、なにか追加の仕掛けが欲しくなります。
ビュー(HTML・MTML)を変更した時にどうするのか、も検討の余地ありです。Theme_GitHubプラグインを併用して別々に適用するのか、等が思い浮かびます。コードを安全に、かつできるだけ簡単に、できれば自動でリリースしたいのです。
※ビューもファイルで記述・管理したい派だけど、管理画面に書くからこそ複数チケットの確認が1つの開発環境でできる、というのはありそうです。昔からある開発手法と今風の開発手法のせめぎ合い。
]]>50音をループし、_beginning
モディファイアでふりがなカラムの先頭の文字を検索するように実装しました。
<mt:setvarblock name="kana_list">あ,い,う,え,お,か,き,く,け,こ,さ,し,す,せ,そ,た,ち,つ,て,と,な,に,ぬ,ね,の,は,ひ,ふ,へ,ほ,ま,み,む,め,も,や,ゆ,よ,ら,り,る,れ,ろ,わ</mt:setvarblock>
<mt:var name="kana_list" split="," setvar="kana_list" />
<mt:loop name="kana_list">
<h2 id="kana<mt:var name="__counter__" escape />"><mt:var name="__value__" escape /></h2>
<mt:eventnames _beginning="$__value__">
<!-- かなが$__value__の値ではじまるイベントの情報 -->
</mt:eventnames>
</mt:loop>
_beginning
モディファイアの実装は以下のようになっており、$beginning_kana
に50音が入ってきて$terms
にセットします。
$terms['kana'] = ['like' => $app->db->escape_like( $beginning_kana, false, true )];
この時、「utf8mb4_general_ci」のように濁点・半濁点を区別する照合順序だと「か」で検索した時に「が」で始まるオブジェクトは含まれません。「utf8mb4_unicode_ci」の場合は「が」で始まるオブジェクトも含まれます。今回は「が」で始まるオブジェクトも含まれて欲しかったので、濁点・半濁点を区別しない照合順序の方がコードがシンプルになります。また、COLLATE句で照合順序を変更する場合とカラムのインデックスが使われないため、約800件のデータを検索した場合はCOLLATE句を使用しない場合に比べて約8倍遅くなりました。(とはいえ、0.0058秒のようなレベルです。)
以下のようにsort_by
を利用してふりがなカラムでソートするように設定しました。
<mt:eventnames _beginning="$__value__" sort_by="kana" sort_order="ascend">
<!-- かなが$__value__の値ではじまるイベントの情報 -->
</mt:eventnames>
この時、「utf8mb4_general_ci」のように濁点・半濁点を区別する照合順序だと、「か」から始まるオブジェクトが全て出力された後に「が」から始まるオブジェクトが出力されます。例えば、
となります。
「utf8mb4_unicode_ci」のように濁点・半濁点を区別しない照合順序の方が自然な並び順に感じます。例えば、
となります。
照合順序に「utf8mb4_general_ci」を使うことが多い印象がありますが、検索結果やソート順が変化するので慎重に選択する必要があることが分かりました。
なお、ふりがなカラムだけ照合順序を変更する場合、モデルJSONに照合順序は含まれないので本番環境・開発環境が分かれている場合は注意が必要です。
]]>MTEntries
で記事リストを表示するテンプレートです。画像の有無・概要の有無で表示が変わります。
<mt:if name="__first__"><ul></mt:if>
<li class="p-entryList__item">
<a href="<mt:entrypermalink escape />" class="p-entryList__link">
<div class="p-entryList__image"><mt:entryassets limit="1"><img src="<mt:assetfileurl escape />" alt=""><mt:else><img src="/assets/images/pic_default_01.webp" alt=""></mt:entryassets></div>
<div class="p-entryList__title"><mt:entrytitle escape /></div>
<mt:if tag="entryexcerpt"><p class="p-entryList__excerpt"><mt:entryexcerpt escape /></p></mt:if>
</a>
</li>
<mt:if name="__last__"></ul></mt:if>
PowerCMS XのテンプレートはPHPにコンパイルされ、それを実行することで値が出力されるので、PHPでテストを書く必要がありそうです。そこで「PHPUnit」を利用することにしました。生成されたHTMLをパースするために「DomCrawler」も利用します。
テストの冒頭は以下の通りです。記事リストテンプレートモジュールをロードしています。
<?php
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;
require_once 'vendor/autoload.php';
require_once '/Users/Shared/Sites/powercmsx/app/class.Prototype.php' ;
class EntryListTest extends TestCase
{
protected $app;
protected $tmplObj;
protected function setUp(): void
{
$app = new Prototype();
$app->init_tags = true;
$app->plugin_paths = [ '/Users/Shared/Sites/powercmsx/app/user_customized_files/plugins' ];
$app->use_plugin = true;
$app->init();
$this->app = $app;
$tmplObj = $app->db->model('template')->get_by_key([
'basename' => 'entry_list',
'workspace_id' => 12,
]);
if ($tmplObj->id) {
$this->tmplObj = $tmplObj;
} else {
throw new Exception('テンプレートの読み込みに失敗しました。', 1);
}
}
mt_entryテーブルのカラムに値が保存されている場合、つまりタイトルや概要などカラムのタイプがリレーション・バイナリ以外のほとんどのものです。言い換えれば$obj->column_name
で値が取得できるものです。
MTEntryTitle
は$ctx->stash('entry');
(実際にはcurrent_context
)からオブジェクトを取り出して値を取得しているので、テスト用のオブジェクトを作成した後stashに入れテンプレートをビルドします。
public function test_タイトルが正しいこと()
{
$app = $this->app;
$entry = $app->db->model('entry')->new();
$entry->title = 'テスト記事です';
$app->ctx->stash('entry', $entry);
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$titleText = $crawler->filter('.p-entryList__title')->text();
$this->assertSame('テスト記事です', $titleText);
}
これで記事タイトルが正しく出力されることが確認できました。
記事概要(excerptカラム)は入力がある場合のみ表示するようにMTIf
タグが入っていますが、テストするには特別な処理は必要はなく、$entry->excerpt
を空にしたり値をセットしたりするだけです。
public function test_概要欄が出力されないこと()
{
$app = $this->app;
$entry = $app->db->model('entry')->new();
$entry->excerpt = '';
$app->ctx->stash('entry', $entry);
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$existExcerpt = $crawler->filter('.p-entryList__excerpt')->count() > 0;
$this->assertSame(false, $existExcerpt);
}
public function test_概要欄が出力されること()
{
$app = $this->app;
$entry = $app->db->model('entry')->new();
$entry->excerpt = '記事の概要が入ります。';
$app->ctx->stash('entry', $entry);
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$excerptText = $crawler->filter('.p-entryList__excerpt')->text();
$this->assertSame('記事の概要が入ります。', $excerptText);
}
これで記事概要の有無に応じて正しく出力されることが確認できました。
アセットの場合は大変でした。アセットカラムはリレーションなのでmt_entryテーブルに値は保存されておらず、$obj->assets
で値やオブジェクトを取得することはできません。
MTEntryAssets
周辺がどのような処理になるのか、コンパイルされたテンプレートを確認するとループの中でhdlr_get_relatedobjs・hdlr_get_objecturlメソッドが実行されることが分かりました。
<?php $c_1d989c=null;$_badff8_old_params['_1d989c']=$_badff8_local_params;$_badff8_old_vars['_1d989c']=$_badff8_local_vars;$a_1d989c=$this->setup_args(['limit'=>'1','this_tag'=>'entryassets'],null,$this);$_1d989c=-1;$r_1d989c=true;while($r_1d989c===true):$r_1d989c=($_1d989c!==-1)?false:true;echo $this->component('PTTags')->hdlr_get_relatedobjs($a_1d989c,$c_1d989c,$this,$r_1d989c,++$_1d989c,'_1d989c');ob_start();?>
<?php $c_1d989c = true; if(isset($this->local_vars['__total__'])&&isset($this->local_vars['__counter__'])&&$this->local_vars['__total__']<$this->local_vars['__counter__']){$c_1d989c=false;}if($c_1d989c ):?>
<div class="p-entryList__image"><img src="<?php echo paml_htmlspecialchars($this->component('PTTags')->hdlr_get_objecturl($this->setup_args(['escape'=>'','this_tag'=>'assetfileurl'],null,$this),$this),ENT_QUOTES)?>
" alt=""></div><?php endif;$c_1d989c=ob_get_clean();endwhile; $_badff8_local_params=$_badff8_old_params['_1d989c'];$_badff8_local_vars=$_badff8_old_vars['_1d989c'];?>
つまり、PTTagsクラスのhdlr_get_relatedobjsメソッドやhdlr_get_objecturlメソッドをどうにかしなければならないのですが、そもそもテスト用のデータをデータベースには保存していないのでこのままではどうにもなりません。テストダブルを用意する必要があります。
hdlr_get_relatedobjsメソッドはデータベースにアクセスせずそのまま$content
を返す、hdlr_get_objecturlメソッドはテスト用の値を返すようにスタブを作成しました。(実はモックとスタブの理解がまだまだです…)
public function test_画像が出力されること()
{
$app = $this->app;
$mockMethods = ['hdlr_get_relatedobjs', 'hdlr_get_objecturl']; // NOTE: モックとスタブの理解がまだまだです…
$ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
$ptTags->init_tags();
$ptTags->method('hdlr_get_relatedobjs')
->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
// $ctx->stash( 'current_context', $asset );
return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
}));
$ptTags->method('hdlr_get_objecturl')
->willReturn('/assets/images/pic_test_01.webp');
$app->ctx->components['pttags'] = $ptTags;
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
$this->assertSame('/assets/images/pic_test_01.webp', $imageSrc);
}
コンパイルしたテンプレートの理解、メソッドの処理内容の理解、作成したスタブをどこにセットするかなど、CMSの実装をかなり知っておく必要があるので挫折しそうです。明らかにフロントエンドの領域を越えていますね。ひとまずこれを実行すると以下のようなHTMLが得られ、画像を指定した場合はその画像が出力されることが確認できました。
<li class="p-entryList__item">
<a href="" class="p-entryList__link">
<div class="p-entryList__image"><img src="/assets/images/pic_test_01.webp" alt=""></div>
<div class="p-entryList__title"></div>
</a>
</li>
画像が指定されていない場合のテストはブロックタグ作成の経験があれば分かりやすいと思うのですが、hdlr_get_relatedobjsメソッド内で$repeat = false
をセットします。
public function test_ダミー画像が出力されること()
{
$app = $this->app;
$mockMethods = ['hdlr_get_relatedobjs']; // NOTE: モックとスタブの理解がまだまだです…
$ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
$ptTags->init_tags();
$ptTags->method('hdlr_get_relatedobjs')
->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
$repeat = false;
return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
}));
$app->ctx->components['pttags'] = $ptTags;
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
$this->assertSame('/assets/images/pic_default_01.webp', $imageSrc);
}
結果、以下のようなHTMLが得られ、画像がない場合は指定したデフォルト画像が出力されることが確認できました。
<li class="p-entryList__item">
<a href="" class="p-entryList__link">
<div class="p-entryList__image"><img src="/assets/images/pic_default_01.webp" alt=""></div>
<div class="p-entryList__title"></div>
</a>
</li>
テンプレートのテストは可能だが大変、フロントエンドだけでは完結しないということが分かりました。辻褄合わせをしてテストを通している感も少しあります…。スタブを作成したリレーション関連のところなどについて、テストをサポートするクラスが提供されると楽になるかもしれませんね。
<?php
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;
require_once 'vendor/autoload.php';
require_once '/Users/Shared/Sites/powercmsx/app/class.Prototype.php' ;
class EntryListTest extends TestCase
{
protected $app;
protected $tmplObj;
protected function setUp(): void
{
$app = new Prototype();
$app->init_tags = true;
$app->plugin_paths = [ '/Users/Shared/Sites/powercmsx/app/user_customized_files/plugins' ];
$app->use_plugin = true;
$app->init();
$this->app = $app;
$tmplObj = $app->db->model('template')->get_by_key([
'basename' => 'entry_list',
'workspace_id' => 12,
]);
if ($tmplObj->id) {
$this->tmplObj = $tmplObj;
} else {
throw new Exception('テンプレートの読み込みに失敗しました。', 1);
}
}
public function test_タイトルが正しいこと()
{
$app = $this->app;
$entry = $app->db->model('entry')->new();
$entry->title = 'テスト記事です';
$app->ctx->stash('entry', $entry);
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$titleText = $crawler->filter('.p-entryList__title')->text();
$this->assertSame('テスト記事です', $titleText);
}
public function test_概要欄が出力されないこと()
{
$app = $this->app;
$entry = $app->db->model('entry')->new();
$entry->excerpt = '';
$app->ctx->stash('entry', $entry);
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$existExcerpt = $crawler->filter('.p-entryList__excerpt')->count() > 0;
$this->assertSame(false, $existExcerpt);
}
public function test_概要欄が出力されること()
{
$app = $this->app;
$entry = $app->db->model('entry')->new();
$entry->excerpt = '記事の概要が入ります。';
$app->ctx->stash('entry', $entry);
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$excerptText = $crawler->filter('.p-entryList__excerpt')->text();
$this->assertSame('記事の概要が入ります。', $excerptText);
}
public function test_画像が出力されること()
{
$app = $this->app;
$mockMethods = ['hdlr_get_relatedobjs', 'hdlr_get_objecturl']; // NOTE: モックとスタブの理解がまだまだです…
$ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
$ptTags->init_tags();
$ptTags->method('hdlr_get_relatedobjs')
->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
// $ctx->stash( 'current_context', $asset );
return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
}));
$ptTags->method('hdlr_get_objecturl')
->willReturn('/assets/images/pic_test_01.webp');
$app->ctx->components['pttags'] = $ptTags;
$html = $app->build($this->tmplObj->text);
$crawler = new Crawler($html);
$imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
$this->assertSame('/assets/images/pic_test_01.webp', $imageSrc);
}
public function test_ダミー画像が出力されること()
{
$app = $this->app;
$mockMethods = ['hdlr_get_relatedobjs']; // NOTE: モックとスタブの理解がまだまだです…
$ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
$ptTags->init_tags();
$ptTags->method('hdlr_get_relatedobjs')
->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
$repeat = false;
return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
}));
$app->ctx->components['pttags'] = $ptTags;
$html = $app->build($this->tmplObj->text);
var_dump($html);
$crawler = new Crawler($html);
$imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
$this->assertSame('/assets/images/pic_default_01.webp', $imageSrc);
}
}
]]>Webサイトを探しても詳しい情報がないのでとりあえず現地に行って色々確認したのですが、以下のようなサービスでした。
配送に対応しているホテルの一部を紹介します。全部で30ホテルほどと提携しているようです。
「駅ナカ手荷物サービス Crosta」は新幹線改札口を出て右斜め前の階段を降りてすぐ右側にあります。宅急便のような伝票があるので、宿泊先と氏名・電話番号を記入して料金を支払うだけで完了です。
広島駅にコインロッカーは混雑しているようですので、上手く活用すると広島の観光等を楽に楽しむことができそうです。ちなみにホテルによると思いますが、ヒルトン広島では荷物は部屋にあげておいてくださいました。
※このページに記載内容は2023年12月2日に確認したものです。
]]>単純にサイトを作るのが面白い、という趣味的なところもあるのですが、以下のようなことも考えました。
最初は単純にAmazon LightsailにNginxとphp-fpmをインストールして公開していたのですが、Amazon S3・CloudFrontの知見を深めたいと考え、S3・CloudFrontを利用して公開するようにしました。Nginxで静的ファイルを配信するだけでも十分速いですが、CloudFrontのエッジサーバーでコンテンツのキャッシュが行われるとさらに速くなりますね。S3へのオブジェクト配置はPowerCMS XのAWS_S3プラグインを利用しているので、普段の更新作業はS3導入前と全く変化がありません。
ユーザー視点でPowerCMS Xを利用していると「記事のタイトルに@を含む時、PostOnTweetでポストすると意図せずメンションを送ってしまうのか」とか、『もしかするとURLマップ毎にメタデータ「Cache-Control」できると良いのでは?』等、いろいろと気付きがあります。前者はテンプレートの調整で対応できますが、後者はもう少し知見を深めて製品にフィードバックできればと考えています。
※Cache-Controlの件は「Amazon CloudFront (Cache Control編) AWS Black Belt Online Seminar」に「キャッシュの有効期限を⻑くすることで、オリジンサーバーへのリクエストを減らせるが、コンテンツの更新が頻繁に発⽣する場合には、有効期限を短くする必要がある」等の記述があったため考え中です。
その他には、自分でプラグインを作成してMroongaで全文検索・類似文書検索(関連するQ&Aの表示)を行う冒険をしていたり、AccessAnalyticsでランキングを表示する準備をしていたり、があります。いろいろ経験を重ねて普段の業務に生かすことができればと考えています。
なお、CSSまでなかなか手が回らないのでQ&A本文がとりあえず見やすければ良いか、という状態ですのでご了承ください。
]]>「スターバックス リザーブ® ロースタリー 東京」は池尻大橋から徒歩14分。車生活の田舎に馴染んでしまったのでなかなか遠いな、と思っていたのですが宿泊していた銀座一丁目から日比谷線で中目黒へ出てバスに乗り換える(菅刈小学校バス停下車)ことで30分ちょっとで着くことができました。8時頃到着することができ、スムーズに入店・注文できたのもラッキーでした。(注文した後、抽出を待つ間にあっという間に大行列ができました。)
店内を「コーヒーのテーマパークみたい」とおっしゃる方がいましたが正にその通り。巨大な焙煎機やたくさんのグッズ、コーヒーを楽しむカウンターなどがあってとてもワクワクする空間でした。「世界に6軒しかない特別なスタバ」というのもテンションを上げてくれます。
初めての訪問だったので何をどう頼んでいいか分からず、ホットコーヒーが飲みたい旨を伝えるといろいろな豆と抽出方法があることを教えてくださいました。今回は「東京 ロースタリー マイクロブレンド™」をケメックスで入れてもらいました。「入れるところをご覧になりますか?」と尋ねられたのでお願いすると「Watch」というカスタマイズが追加され、バリスタさんとお話をしながらコーヒーを入れてもらうことができました。スッキリとして飲みやすく、やさしい味のコーヒーでした。
朝食を食べて出かけたので今回はパンやスイーツなどは食べませんでしたが、次回はパンを食べることができたらいいなと思いました。ただ、すぐに行列ができるので待つのが苦手な自分には少しハードルが高いかも。気温が暖かいうちは3階・4階のテラス席も気持ちよさそうです。
「スターバックス リザーブ® ストア 銀座マロニエ通り」は朝7時から開店しており、「プリンチ ブレックファスト」…いわゆるモーニングが楽しめるということで行ってみました。新卒で銀座一丁目にて働いていた頃は通常のスタバだったので、店内に入ると「ここがリザーブ店に変わったのか」ととても懐かしい気分でした。
7時に行ったのでこちらもスムーズに入店・注文。「プリンチ ブレックファスト」は写真で見るよりもボリュームがあり、見た目もきれいで食欲をそそるものでした。スターバックスでこのようなモーニングが食べられると朝からとても楽しいです。
どちらの店舗も朝だけでなく、昼・夜に行って食事とともにコーヒーを楽しみたい、と思いました。次に東京に行くときもぜひ行ってみたいです。「スターバックス リザーブ® ロースタリー 東京」限定のカードも買ったので、福山のスタバで話のタネにでもなればと思っています。
]]>これまでブロックの編集画面案を掲載してきましたが、正式に実装を行いました。入力項目は以下のようになりました。
template
)dt,dd
やtr>th+td
等が増減する時チェック)この登録内容を基に編集画面のブロックエディタが表示されるよう、管理画面のテンプレート上にてVue.jsとMTMLを結合し、無事に動作しています。
その他、便利な機能などを挙げてみます。
見出しブロックはシステムスコープに登録して使いたいという要望もあるかもしれませんね。ブロック定義の出力はmt:objectloop
を利用しているので、設定に応じてworkspace_id="0,4"
などにすると実現できそうな感じがします。上・下・削除ボタンをもう少しコンパクトに・見栄え良くする改善もしていきたいと考えています。
その他、ブロック定義・コンテンツ出力時のテンプレートは自由に書くことができるので、テーブル編集用UIライブラリ「a-table.js」を組み込むなどもできるようになるかもしれません。
しかし、リッチなエディタに頼りすぎると本プロジェクトが目指すブロックエディタ…入力したコンテンツを設計済のHTML/CSSコンポーネントで出力できるという意義がなくなります。既に実装しているインラインエディタも利用できるボタンをかなり絞り込んでいます。(リンクと太字ぐらいは使いたいだろうなと。)セミナーイベント「ジャムスタックチョットデキル!! シブヤ!!」のセッション「APIスキーマ設計Tipsと (新)リッチエディタについて」でも語られていますね。
リリース時の懸念事項として、後からブロック定義を変更することになるとPHP等でデータのマイグレーションを行う必要があることです。テスト中に意図した動作にならないところがありphpMyAdminでデータを編集しました。少々怖いですね。
]]>今日はテンプレートについて検討してみました。ブロックエディタで入力したデータは以下のようなJSONで保存されています。id
・type
・fields
以外はブロックのコンポーネントを作成する時に自身で決めたキーです。v-model="element.text"
等とテンプレートに記述しています。(コンポーネントの記述例は昨日の記事をご覧ください)
[
{
"id":"zolp7dw3",
"type":"heading",
"text":"PowerCMS Xクラウド価格表",
"additional_data":2
},
{
"id":"9ovsgrde",
"type":"text",
"text":"<p>下記のオプションがご利用頂けます。掲載の価格は全て税込です。</p>"
},
{
"id":"rnrshkp8",
"type":"table",
"heading_width":70,
"fields":[
{
"id":"23i8uycy",
"th":"データベースサーバー冗長化",
"td":"<p>16,500円/月</p>"
},
{
"id":"6vaknlbl",
"th":"Web サーバー ストレージ容量追加",
"td":"<p>27,500円/月</p>"
},
{
"id":"nurfoi47",
"th":"データベースサーバー ストレージ容量追加",
"td":"<p>8,250円/月</p>"
}
]
}
]
ブロックエディタのカラム「block_edit」から<mt:entryblockedit from_json="blocks" />
でデータを取り出しfrom_json
でJSON文字列をデコードします。from_json
の実装が$json = json_decode( $json, true );
なので配列で格納されます。
mt:loop
を使いブロックのデータをループし、ブロックのタイプに応じたテンプレートを記述します。PowerCMS 6のフィールドブロックビルダーと違いブロック作成時にコンテンツを出力するためのテンプレートタグを決めていないため、1つのブロックでさまざまな出力ができると考えられます。シンプルに見出し・文章・テーブルを生成するMTMLを書いてみました。
<div class="content">
<mt:entryblockedit from_json="blocks" />
<mt:loop name="blocks">
<mt:if name="type" eq="heading">
<mt:if name="additional_data" eq="2">
<h2><mt:var name="text" escape /></h2>
<mt:elseif name="additional_data" eq="3">
<h3><mt:var name="text" escape /></h3>
<mt:elseif name="additional_data" eq="4">
<h4><mt:var name="text" escape /></h4>
</mt:if>
<mt:elseif name="type" eq="text">
<mt:var name="text" />
<mt:elseif name="type" eq="table">
<mt:loop name="fields">
<mt:if name="__first__"><table></mt:if>
<tr>
<th><mt:var name="th" escape /></th>
<td><mt:var name="td" remove_html /></td>
</tr>
<mt:if name="__last__"></table></mt:if>
</mt:loop>
</mt:if>
</mt:loop>
</div>
結果、以下のようなHTMLと表示が得られました。
<div class="content">
<h2>PowerCMS Xクラウド価格表</h2>
<p>下記のオプションがご利用頂けます。掲載の価格は全て税込です。</p>
<table>
<tr>
<th>データベースサーバー冗長化</th>
<td>16,500円/月</td>
</tr>
<tr>
<th>Web サーバー ストレージ容量追加</th>
<td>27,500円/月</td>
</tr>
<tr>
<th>データベースサーバー ストレージ容量追加</th>
<td>8,250円/月</td>
</tr>
</table>
</div>
mt:loop
と自分で決めたキーを用いて容易に出力できたのが良かったです。
Vue.jsの知識をもっと深めた方が良いのか?などとモヤモヤし2022年10月以来手が止まっていたのですが、ここ数日でコードを再度眺め整理することができ、CMSで管理・出力したい(ないし手書きしてもらいたい)内容とエディタのコアとなるコードが明確になりました。(知識は深めたい…)
emits
でひたすら親コンポーネントにバケツリレーしていたのですが、BlockControllerコンポーネント自身で完結できるようになりましたユーザーが入力するブロックの定義は以前にも掲載しましたが下記のような感じです。
const LayoutImage = Vue.defineComponent({
props: ['element', 'index'],
data() {
return {
store,
}
},
template: `
<div class="row">
<div class="col-4">
<assetselector :element="element" :index="index" />
</div>
<div class="col-8">
<editor
<mt:if name="component_blocks_tinymce_api_key">api-key="<mt:var name="component_blocks_tinymce_api_key" escape />"</mt:if>
:init="store.initTextEditor"
v-model="element.text"
class="form-control inline-mce text"
/>
</div>
</div>
`,
});
テキストエリアでTinyMCEを使う設定を入れているのでdata
が入ってきますが、見出しテキストと見出しレベルの選択を入力するぐらいならばもっと簡単です。Bootstrapのクラス名等を外したコードを示します。
const Heading = Vue.defineComponent({
props: ['element'],
template: `
<input type="text" v-model="element.text">
<div>
<label>
<input type="radio" v-model.number="element.level" :value="2">
見出し2
</label>
<label>
<input type="radio" v-model.number="element.level" :value="3">
見出し3
</label>
<label>
<input type="radio" v-model.number="element.level" :value="4">
見出し4
</label>
</div>
`,
});
課題は上記ブロックの定義の他にブロックを追加した時にリアクティブなデータオブジェクトに追加するオブジェクトの定義をどう追加してもらうか。ブロックの編集画面が実現すれば、PHPで解決できるかもしれません。
// ブロック毎の保存データを定義
window.compoenentBlocksSettings.setBlockData = (app, blockData, type) => {
switch (type) {
case 'heading':
blockData['text'] = '';
blockData['level'] = 2;
break;
case 'text':
blockData['text'] = '';
break;
case 'image':
blockData['asset_id'] = 0;
break;
case 'layout_image':
blockData['asset_id'] = 0;
blockData['text'] = '';
break;
※v-model.number
の記述を忘れていましたので修正しました。テキストと数値を個別に指定できるようにしました。
それにしても、エディタのコアなコードは100行ぐらいで後はひたすらブロックのコンポーネント定義をしているだけというのがすごいなと思っています。リアクティブなデータオブジェクトを操作するだけで編集画面に反映されるのが個人的には楽しいです。メンテナンスが楽そうですがユーザーにもVue.jsの知識が求められるのが難点でしょうか。PowerCMS 6の「フィールドブロックビルダー」はノーコード・ローコードな感じですよね。
]]>コードも書いてもらえると聞いていたので「Movable TypeでHTMLのul要素を使用して記事を一覧表示するテンプレートを作成してください。」と入力したところ、以下のような出力になりました。
<ul>
<MTEntries>
<li><a href="<$MTEntryPermalink$>"><$MTEntryTitle$></a> - <$MTEntryDate format="%Y/%m/%d"$></li>
</MTEntries>
</ul>
ul要素の開始・終了タグをmt:if
で囲んでほしい気がするけれど要点はきちんとおさえている印象で、少し手直しすればそのまま利用できそうです。すごい。
次に「Movable Typeで記事のタイトル・公開日・本文を表示する記事詳細ページ(HTML)のテンプレートタグを作成してください。」と入力してみたところ、以下のような出力になりました。こちらも要点はきちんとおさえていますね。
<html>
<head>
<title><$MTEntryTitle$></title>
</head>
<body>
<h1><$MTEntryTitle$></h1>
<p>公開日:<$MTEntryDate format="%Y/%m/%d"$></p>
<div><$MTEntryBody$></div>
</body>
</html>
AIが僕たちの代わりにテンプレートタグを書いてくれる時代が本当に来そうと感じました。
少し時間をおいてもう一度ログインしたところ、よく見るチャット画面が出てきました。今度はこのブログにも使用しているCraft CMSで試したところ、こちらも良い感じにテンプレートが生成されました。
<ul>
{% for entry in craft.entries.section('news').order('postDate desc') %}
<li>
<a href="{{ entry.url }}">{{ entry.title }}</a>
<p>{{ entry.postDate|date('F j, Y') }}</p>
</li>
{% endfor %}
</ul>
ちなみに「PowerCMS」でも試したものの期待したテンプレートタグは生成されませんでした…と思いきや、PowerCMSについてもチャットのような画面でもう一度試したところ、Movable Typeと同じように記事一覧を表示するテンプレートタグが出力されました。MTMLというヒントを追加したからかもしれません。ChatGPTにPowerCMSのドキュメントを巡回してもらうか、ドキュメントを改良するか、なのでしょうか? SEOやMEOのように「AIO (AI Optimization)」なんて言う日が来るかもしれませんね。
最後に、ChatGPTに対する挑戦状的に「Movable Typeで利用するプラグインを作る時のボイラープレートを作ってください」と入力してみたところ、yamlを使わない形式(Textileプラグイン)のようなコードが出力されました。やるな、ChatGPT。
package MT::Plugin::YOUR_PLUGIN_NAME;
use strict;
use warnings;
use base qw( MT::Plugin );
our $VERSION = '0.01';
my $plugin = MT::Plugin::YOUR_PLUGIN_NAME->new({
name => 'YOUR_PLUGIN_NAME',
version => $VERSION,
description => 'A description of your plugin',
author_name => 'Your Name',
author_link => 'http://example.com/',
plugin_link => 'http://example.com/',
doc_link => 'http://example.com/',
});
MT->add_plugin( $plugin );
sub instance { $plugin; }
1;
]]>ホテルの予約はOTAを使わず直接予約することが多いので、今回もMarriott Bonvoyの公式サイトから予約しました。予約時に希望を伝える欄があったので、アーリーチェックイン・高層階をチェック、pajamasを自由入力欄に入力しました。
当日は標準チェックイン時刻の15時より少し早めの14時30分頃に到着したのですが、そのままチェックインすることができました。部屋はなんと19階でほぼ最上階。広島駅新幹線口方面の景色を思い切り楽しむことができる部屋です。安佐南の辺りも見えたように思います。
部屋のタイプは最も標準的な「デラックス客室 1キング」です。ホテルに泊まる時ぐらいは広い部屋でくつろぎたい、と思うタイプなので35平米のゆったりした部屋はとても嬉しいです。窓も大きくて光が明るいのも好きです。デスクも適度な大きさでMacBook Airを持ってきて1日研究開発合宿的なことをしてみたくもなります。ベッドの上にはリクエストしたパジャマも置いてありました。
最近なぜかエネルギー切れを起こして不調になりがちなので、ライブ前は少ししっかりした軽食を食べたいと思っていました。そこでルームサービスで「クラブサンドイッチ」をお願いしました。チキン・ベーコン・たまご・レタス・トマトなどが入っています。フライドポテトかサラダも付くそうですが、そこまで食べられそうにないのでサンドイッチのみにしてもらいました。お値段は高めですが焼きたてのサンドイッチでしっかりお腹を満たすことができて満足しました。
ちなみに夕食によさそうなものとして鯛のロースト、チキンコンフィー、チーズハンバーグステーキなどもあるようで食べてみたかったです。
暖かくなる4月に京都や大阪に行こうと考えるのですが結構ホテルが高騰していて今回のシェラトンぐらいの値段がするようです。コロナ関連で色々緩和が進み多くの人が動くからでしょうか。春はもう一度シェラトングランド広島を訪ねてのんびりしようかな、などと考えています。その際は宿泊者割引が利用できるブッフェレストラン「ブリッジ」で夕食をとろうか、と楽しみにしています。
]]>「102Lover 結び」さんを知ったのは「オトナもコドモも心躍る 鉄道カフェへGO! ー タウン情報ウインク-広島・福山-」を見たこと。福山から尾道はバイパスですぐですし、なにより橋・駅・踏切などが揃ったコースで鉄道模型を走らせるのは気分が全然違います。
2022年には尾道をイメージしたジオラマにパワーアップ。今も行く度に進化を見ることができます。僕はカウンター側、カウンターを背にして左側から眺める風景が好きです。2022年末には歩道橋やビルなどが増えていてリアルさが増しました。
尾道をイメージしたジオラマには尾道を走っている115系・227系・EF210などが合うなと感じています。もちろん「こんな車両が走ると楽しいだろうな」と思いながら自分の好きな車両を走らせるのも良しです。写真は1番線にKATOの227系を止めてEOS R6とマクロレンズでじっくり構えて撮影してみました。走らせるも良し、ゆっくり撮るも良しです。ちなみにポポンデッタのエネルギーチャージャー付室内灯を使用するとちらつきを防ぐことができて相性が良いようです。
3番線は山際を登るイメージで楽しめます。崖や木々がとてもリアル。山岳区間を走る車両が似合うように思います。カーブの関係で短めの車両が良いかも、とのことでした。
その他、オリジナルイラスト入りグッズがSUZURIで販売されています。中でもトートバッグはNゲージの鉄道模型を3セット入れることができ、お店を訪ねるときにちょうどぴったりなサイズでした。
お店の雰囲気はゆったり、店主さんも優しい方で、日々忙しい仕事を忘れのんびり楽しめる空間です。まさに路地裏の秘密基地的です。Instagramのハッシュタグは「#102lover結び」のようで、こちらも楽しませてもらっています。これからも末永くよろしくお願いします。
102Lover 結び
広島県尾道市長江1丁目23-31
駐車場なし
お店には駐車場がないのでいつも特P(とくぴー)で「長江1-21-14駐車場」を予約して利用しています。その他、お店から少し海側に走ったところにコインパーキングがあります。
]]>JIS X 8341 3:2016の適合レベルAA準拠を目標とするWebサイトのコーディングを担当しました。作業過程で「達成基準 1.4.4 テキストのサイズ変更」について確認するためにiPhone 12 ProのSafariにおいてテキストサイズの拡大を行い表示チェックをしました。結果、コンテンツ又は機能が損なわれないことが分かったのですが(達成方法 G179)、テキストサイズを2倍にすると2カラムで表示している箇所の各カラム幅(コンテナ幅)が狭く5文字ぐらいしか表示されないために「もしかすると見づらいのでは? 1カラムにならないかな?」と疑問を感じました。
アドベントカレンダーにエントリーし記事の検討始めてから気付いたのですが、これはアクセシビリティというよりユーザビリティの問題かもしれません。
PC版とスマホ版のデザインが上がってきてそれを基にコーディングするのがよくあるケースでしょうか。私のプロジェクトでもそうでした。スマホ版デザインで2カラム、PC版デザインで3カラムとなっていたのでまず2カラムのスタイルを書き、ビューポートが広い端末に向けてメディアクエリで3カラムになるスタイルを書きました。
.c-index {
display: flex;
flex-wrap: wrap;
gap: 2em 5.49%;
}
.c-index__item {
flex-basis: 47.255%;
}
.c-index__item::before {
display: block;
content: "\200B"; /* add zero-width space https://gerardkcohen.me/writing/2017/voiceover-list-style-type.html */
width: 0;
height: 0;
}
@media screen and (min-width: 48em) {
.c-index__item {
flex-basis: 29.673%;
}
}
上記コードだと最小カラム数が2カラムになってしまうので、1カラムになるスタイルから書き始め、メディアクエリで2カラム・3カラムのスタイルを書けば良かったのか、と考えました。デザインデータにはない領域にも目を向ける必要がありますね。
.c-index {
display: flex;
flex-wrap: wrap;
gap: 2em 5.49%;
}
.c-index__item {
flex-basis: 100%;
}
.c-index__item::before {
display: block;
content: "\200B"; /* add zero-width space https://gerardkcohen.me/writing/2017/voiceover-list-style-type.html */
width: 0;
height: 0;
}
@media screen and (min-width: 20em) {
.c-index__item {
flex-basis: 47.255%;
}
}
@media screen and (min-width: 48em) {
.c-index__item {
flex-basis: 29.673%;
}
}
改善後のCSSを基にブラウザで表示を確認すると、テキストサイズを2倍にした時1カラムで表示されるようになりました。
あとで気付いたのは、iPhoneユーザーガイドでは「テキストの拡大」とありますが余白が2倍になっているのでテキストだけの拡大ではなく画面全体が200%ズームしているのかと気付きました。SafariのWebインスペクタでbody要素を確認すると195pxであることが分かります。(100%時は390px) AndroidのChromeではテキストのみ拡大されるのでリフローされませんでした。
ここまでは達成基準 1.4.4を基にしたユーザビリティの話のようでしたが、WCAG 2.1だと「達成基準 1.4.10 リフロー」があることを知りました。(WCAG 2.1の理解不足ですね…。)WCAG 2.1 解説書を読み進めると、ここまでに記述した「よりよい感じにコンテンツをリフローさせる」という話題が関係するように感じますし、CSSは「達成方法 C31 コンテンツをリフローするために CSS Flexbox を使用する」に該当するのかと考えました。C31の検証手順に従って改善後のCSSを400%拡大で表示を確認すると、100%表示で3カラムになるコンテンツは400%拡大で2カラムにリフローされコンテンツが横スクロールなしで利用できることが確認できました。
iPhone 12 Proでは最高でも300%の倍率になりますが、リフローされて全てのコンテンツが横スクロールなしで利用できました。(「320CSSピクセルに相当」ではないけれど)
でもやはり達成基準 1.4.10 リフローの話を持ち出したのはちょっとこじつけっぽいかな、すみません。
2日目のsaimari@Relic Inc.さんの記事を拝見し、「実装ポイント③」のサンプルを僕なりに書いてみました。確かに今年は拡大についていろいろ考えさせられた年でした。デザインをきちんと再現できるCSS、FLOCSS等で破綻しにくいCSSを書き、さらに拡大にも対応するというのはなかなかハードですね。
]]>mt:archivelist
タグの属性でカラム名="値"
の形式で条件を付けることはできないためmt:archivelist
タグのソースコードを確認したところ、pre_archive_list
コールバックがあることが分かったのでプラグインを作成しました。テンプレートを判別する方法だけが分からず社長の野田さんに質問したところ、「$ctx->stash( 'current_template' )
にテンプレートオブジェクトが入っている」とのヒントを頂き、現在再構築対象のテンプレートのベースネームを取得することができました。
以下サンプルコードです。
/**
* pre_archive_listコールバックでの処理(記事)
*/
public function pre_archive_list_entry ( $cb, $app, &$wheres ) {
$target_templates = [
'culture_entry_list',
'japanese_entry_list',
];
$current_template = $app->ctx->stash( 'current_template' );
if ( array_search( $current_template->template_basename, $target_templates ) !== false ) {
$model = $cb[ 'model' ];
$wheres[] = "{$model}_information_display_flag = 1";
}
}
後は普通にmt:archivelist
タグを利用してテンプレートを書くだけです。PHPでオブジェクトの取得条件を制御する方法を覚えると作業が捗ります。
カテゴリ別かつ年度別アーカイブの場合、$wheres[] = "{$model}_id IN (...)";
で対象の記事IDを指定すると良さそうですがIN句が膨大になる可能性があります。MySQLはIN句の上限はないようですがPADOを利用して一時テーブルを作成することができるか試したところ意図した結果を得ることができました。同一カテゴリで年度違いの場合は一時テーブルが使い回されます。(ただしインデックステンプレートの編集画面で「保存と再構築」を押した場合は使い回しできない模様)
$category_id = $app->ctx->vars[ 'current_object_id' ];
if ( $app->param( '_model' ) !== 'template' && array_key_exists( $category_id, $this->temporary_table_nums ) ) {
$table_num = $this->temporary_table_nums[ $category_id ];
} else {
$table_num = rand( 100000, 999999 );
$this->temporary_table_nums[ $category_id ] = $table_num;
$app->db->db->query( "CREATE TEMPORARY TABLE `mt_tmp_{$table_num}` (entry_id INT);" );
$sth = $app->db->db->prepare( "INSERT INTO `mt_tmp_{$table_num}` (entry_id) SELECT `relation_from_id` AS entry_id FROM `mt_relation` WHERE `relation_from_obj` = 'entry' AND `relation_to_obj` = 'category' AND `relation_to_id` = :category_id" );
$sth->execute( [ 'category_id' => $category_id ] );
}
$wheres[] = "entry_id IN (SELECT entry_id FROM mt_tmp_{$table_num})";
mt:archivelist
と上記コードの組み合わせた場合、そしてmt:entries
で対象記事を全件回して処理する場合をmt:speedmeter
で比較したところ、mt:archivelist
を使用した方が速いという結果になりました。(表の単位は秒・M2 MacBook Airにて計測・カレントリンクの処理をテンプレート側でやる体でmt:cacheblock
は使用せず)
mt:archivelist | mt:entries | |
---|---|---|
1年目 | 0.0156 | 0.0487 |
2年目 | 0.0038 | 0.0185 |
3年目 | 0.0034 | 0.0156 |
4年目 | 0.0034 | 0.0205 |
5年目 | 0.0036 | 0.016 |
6年目 | 0.0071 | 0.0159 |
7年目 | 0.0033 | 0.0158 |
8年目 | 0.0031 | 0.0156 |
9年目 | 0.0034 | 0.0201 |
10年目 | 0.0033 | 0.0157 |
11年目 | 0.0034 | 0.0162 |
12年目 | 0.0033 | 0.0156 |
13年目 | 0.0035 | 0.0197 |
合計 | 0.0602 | 0.2539 |