はじめに
Docker + WordPressの延長線上のお話。
Windows / MacOS共通で、VS CodeとDocker Desktopだけを使って、WordPressをコンテンツ管理システム(CMS)に利用しつつヘッドレスCMSへと発展させてみる。
ビューの部分だけを自力実装してみる方向へ。
WordPressは広く使われているぶん、ハッキング/クラッキングによる攻撃を受けやすい。
そのためウェブサイトとして表示する部分だけをWordPressから切り離して、データベースからの読み取りだけを行う。
そしてWordPressは記事を管理する(データベースの読み書きを行う)重要フォルダとしてセキュリティをガチガチに固める方法。
セキュリティ強度は劇的に改善するのがメリットな一方で、WordPressの関数をそのまま使えない&MySQLからの読み取りを直接自力でやらないといけない(し、もちろんWordPressに由来するテーブル構造を把握しないといけない)ので技術レベルは確実に上がっているのがデメリットというところ。
正確にいうなら、難しいというより(全部やらなくちゃなので)手間がかかるのがやや面倒。
そういう面倒な部分を解決してくれるのがViteなりVueなりReactなりのWebフレームワークさんだけども今回はあえて自力でなんとかしてみようって趣旨でいきます。
プロジェクトの初期化
前提
Docker + WordPressでの初期インストール手順の抜粋。
- WindowsはWSLをインストールする
- VS Codeでのターミナルのプロファイルは、Windowsがwslで、MacOSはzshでも何でも
- パソコンを起動したら、3306番ポートを使っているプロセスを忘れずに殺しておくこと
Windows
Windows PowerShellから次を実行。
netstat -ano | Select-String ":3306"
下記のような使用プロセスがあるなら(例では99999がプロセスID)
TCP 0.0.0.0:3306 0.0.0.0:0 LISTENING 99999
下記コマンドで殺す。
taskkill /F /PID 99999
MacOS
ターミナルから次を実行。
sudo lsof -i:3306
下記のような使用プロセスがあるなら(例では99999がプロセスID)
httpd 99999 4u IPv4 0x000000000000000 0t0 TCP *:http (LISTEN)
下記コマンドで殺す。
`sudo kill 99999
プロジェクトフォルダの作成
新しいフォルダを作成して、VS Codeの新しいウィンドウを開いて、作ったフォルダをドラッグ&ドロップ。
いつでもコマンドを実行できるようにターミナルを開いておく(Windows / MacOS共通: Control + @)
機能ごとのフォルダを作る
次のコマンドを一気にターミナルへコピペして、フォルダを全部作ります。
mkdir config
mkdir config/mysql
mkdir config/mysql/data
mkdir config/web
mkdir html
mkdir html/app
mkdir html/src
mkdir html/src/css
mkdir html/src/img
mkdir html/src/js
mkdir html/src/public
ファイルを作る(1)
- config/web/Dockerfile
- docker-compose.yml
- .gitignore
- html/app/index.php
DockerはApache + PHP + xdebug + MySQL同梱セット。
WordPress向けに、mod_rewrite.soも有効化させたり、WebPを扱えるようにするためにGDやImageMagickを追加してたりするよ。
index.phpはDockerの起動確認テストにしか使わないのですぐ消える運命にあります。
config/web/Dockerfile
# Apache + PHP
FROM php:8.3.7-apache
# Install
RUN apt-get -y update && apt-get -y upgrade
# - GD
RUN apt-get install -y zlib1g-dev libzip-dev libpng-dev libjpeg-dev \
&& docker-php-ext-configure gd --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd
# - imagick
RUN apt-get install -y libmagickwand-dev imagemagick wget \
&& wget https://pecl.php.net/get/imagick-3.7.0.tgz \
&& tar xzvf imagick-3.7.0.tgz \
&& cd imagick-3.7.0 \
&& phpize && ./configure && make && make install \
&& docker-php-ext-enable imagick
# - zip
RUN pecl install zip && docker-php-ext-enable zip
# - intl
RUN docker-php-ext-configure intl && docker-php-ext-install intl
# - exif
RUN docker-php-ext-install exif
# Apache setting: enable mod_rewrite
RUN a2enmod rewrite
# PHP: xdebug
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
# PHP: MySQLi
RUN docker-php-ext-install mysqli \
&& docker-php-ext-enable mysqli
# Clean up
RUN apt-get clean \
&& rm -rf /var/lib/apt/lists/*
docker-compose.yml
services:
# web (front-end)
web:
build: ./config/web
ports:
- '8880:80'
volumes:
- ./html/app:/var/www/html
environment:
WORDPRESS_DEBUG: 1
WP_ENVIRONMENT_TYPE: local
restart: always
depends_on:
db:
condition: service_started
networks:
- backend
# phpMyAdmin
phpmyadmin:
image: phpmyadmin:apache
restart: always
ports:
- '8881:80'
environment:
PMA_ARBITRARY: 1
PMA_HOST: db
PMA_USER: root
PMA_PASSWORD: docker_pw
depends_on:
db:
condition: service_started
networks:
- backend
# DB
db:
image: mysql:8.0.18
platform: linux/x86_64
container_name: db
ports:
- '3306:3306'
volumes:
- ./config/mysql/data:/var/lib/mysql
environment:
MYSQL_DATABASE: docker_db
MYSQL_ROOT_PASSWORD: docker_pw
MYSQL_USER: docker_user
MYSQL_PASSWORD: docker_pw
MYSQL_ROOT_HOST: '%'
TZ: Asia/Tokyo
command: mysqld --lower-case-table-names=2
networks:
- backend
volumes:
node_modules:
networks:
backend:
.gitignore
# Common
node_modules/
.vscode/*
.DS_Store
html/app/
# Docker
config/mysql/data/
html/app/index.php
<!DOCTYPE html>
<html lang="ja">
<head>
<title>Test</title>
</head>
<body>
<label>
<span>DOCUMENT_ROOT: </span>
<index type="input" value="<?php var_dump($_SERVER['DOCUMENT_ROOT']); ?>">
</label>
</body>
</html>
Dockerの起動テスト
Docker Desktopを起動した状態で、次のコマンドを実行する。
docker compose up -d
初回ビルド時は結構待たされると思います。
お茶菓子でも用意して、ゆるりと寛がれるがよい。
http://localhost:8880にアクセスしてみて、Apache+PHPが動いていることを確認。
この時に表示されるDOCUMENT_ROOTは何だったかをメモしておいてください。
多分「/var/www/html」だと思いますが、違っていたらメモ必須。
続いてhttp://localhost:8881にアクセスして、phpMyAdmin(=MySQL)が動いていることを確認する。
ちゃんと動いているなら起動テストは終了です。
テスト用に作ったhtml/app/index.phpは不要なので削除。
docker compose down -v
上記のコマンドでDockerコンテナを終了させられますが、そのまま開発は継続するのでDockerくんには起動したままになってもらいます。
一度終了させたDockerコンテナは「docker compose up -d」で再び起動しますが、初回ビルドを済ませているので割と早めに着手できます。
WordPressをインストールする
ダウンロードページに行って、「WordPress x.x.x をダウンロード」をクリック。
最新バージョンを示す数字はその時々によって変わります。
ダウンロードしてきたZIPファイルをhtml/appフォルダに移動してきて、そのまま解凍。
html/app/wordpressフォルダとして展開されるはずです。
不要なZIPファイルはゴミ箱にポイで。
前回の直接WordPressを使う場合とは異なり、「http://localhost:8880/wordpress」などとしてフォルダを1階層挟むようになったのが大きな違い。
Dockerコンテナを起動させてない場合はここで
docker compose up -d
Dockerコンテナが有効になっている状態でhttp://localhost:8880/wordpressにアクセスしてWordPressのセットアップ画面を表示させます。
設定内容は前回と同じですが、最後に行う表示テストでのURLだけが違っているよ。
ようこそ
「さあ、始めましょう!」をクリック。
設定(1)
- データベース名: docker_db
- ユーザー名: docker_user
- パスワード: docker_pw
- データベースのホスト名: db
- テーブル接頭辞: wp_
「送信」をクリック。
「インストール実行」をクリック。
設定(2)
- サイトのタイトル: test(適当なものでよい)
- ユーザー名: docker_user
- パスワード: docker_pw
- 脆弱なパスワードの使用を確認: チェックを入れる
- メールアドレス: admin@docker.test(適当なものでよい)
- 検索エンジンがサイトをインデックスしないようにする: チェックを入れる
「WordPress をインストール」をクリック。
「ログイン」をクリック。
WordPressダッシュボードへのログイン画面
- ユーザー名またはメールアドレス: docker_user
- パスワード: docker_pw
- ログイン状態を維持する: チェックを入れる
「ログイン」をクリック。
WordPressのインストール完了を確認
WordPressダッシュボードから「サイトを表示」をクリック
http://localhost:8880/wordpressが開かれるので、WordPressテーマが表示されているかを確認すれば完了です。
制作環境を作る
gulp.jsを使うので、必要なパッケージを導入します。
ターミナルから次を実行。
npm init -y
npm i -D cross-env dotenv gulp gulp-plumber gulp-filter gulp-rename gulp-pug glob fs-extra sass gulp-sass gulp-postcss autoprefixer postcss-csso tailwindcss gulp-sharp-optimize-images esbuild
npm i -D --include-optional sharp
npx tailwindcss init
ファイルを作る(2)
- package.json
- tailwind.config.js
- .env
- gulpfile.mjs
package.json
scripts(変更前)
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
scripts(変更後)
"scripts": {
"dev": "cross-env NODE_ENV=local gulp dev",
"build": "cross-env NODE_ENV=local gulp",
"build:xserver": "cross-env NODE_ENV=xserver gulp"
},
tailwind.config.js
content(変更前)
content: [],
content(変更後)
content: ["./html/src/**/*.{pug,scss}"],
.env
.envの*_XSERVERの設定は、レンタルサーバのドメインに合わせて書き換えるなり。
VERSION="1.0.0-2000.0101.1230"
DOMAIN_LOCAL="http://localhost:8880"
DOMAIN_XSERVER="https://phew.gothicum.xyz"
DOCROOT_LOCAL="/"
DOCROOT_XSERVER="/"
SUBDIR_LOCAL=""
SUBDIR_XSERVER=""
PUBLIC_LOCAL=""
PUBLIC_XSERVER=""
gulpfile.mjs
/**
* load package
*/
import { configDotenv } from "dotenv";
import { watch, series, src, dest } from 'gulp';
import gulpPlumber from 'gulp-plumber';
import gulpFilter from 'gulp-filter';
import rename from 'gulp-rename';
import { glob } from 'glob';
import fs from 'fs-extra';
import sharpOptimizeImages from 'gulp-sharp-optimize-images';
import gulpPug from 'gulp-pug';
import * as dartSass from 'sass';
import gulpSass from 'gulp-sass';
const sass = gulpSass(dartSass);
import gulpPostCss from 'gulp-postcss';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import csso from 'postcss-csso';
/**
* const variable
*/
const dotenvData = configDotenv({path: '.env'}).parsed ?? {};
const nodeEnv = (process.env.NODE_ENV ?? '').split('-');
/**
* 必要なURLやPATHを.envから取得
* @requires PRODモードのパス書き換えを忘れないように
*/
let dirpath = (() => {
let mode = 'production-local';
let strictAbsPath = [dotenvData?.DOMAIN_LOCAL, dotenvData?.DOCROOT_LOCAL, dotenvData?.SUBDIR_LOCAL].join('');
let rootAbsPath = [dotenvData?.DOCROOT_LOCAL, dotenvData?.SUBDIR_LOCAL].join('');
let innerPublicPath = [dotenvData?.PUBLIC_LOCAL].join('');
let outerPublicPath = [dotenvData?.DOMAIN_LOCAL, dotenvData?.DOCROOT_LOCAL, dotenvData?.SUBDIR_LOCAL, dotenvData?.PUBLIC_LOCAL].join('');
let version = dotenvData?.VERSION ?? '';
let isTerminate = false;
for(let i = 0, l = nodeEnv.length; i < l; i ++){
switch(nodeEnv[i]){
case 'xserver':
mode = 'production-' + nodeEnv[i];
strictAbsPath = [dotenvData?.DOMAIN_XSERVER, dotenvData?.DOCROOT_XSERVER, dotenvData?.SUBDIR_XSERVER].join('');
rootAbsPath = [dotenvData?.DOCROOT_XSERVER, dotenvData?.SUBDIR_XSERVER].join('');
innerPublicPath = [dotenvData?.PUBLIC_XSERVER].join('');
outerPublicPath = [dotenvData?.DOMAIN_XSERVER, dotenvData?.DOCROOT_XSERVER, dotenvData?.SUBDIR_XSERVER, dotenvData?.PUBLIC_XSERVER].join('');
isTerminate = true;
break;
}
if(isTerminate) break;
}
return {
mode: mode,
strictAbsPath: strictAbsPath,
rootAbsPath: rootAbsPath,
innerPublicPath: innerPublicPath,
outerPublicPath: outerPublicPath,
version: version,
}
})();
/**
* task: copy
* @listens html/src/public/*
* @exports html/app*
*/
const task_copy = async done => {
let promises = [];
let files = await glob('./html/src/public/**/!(_)*', {ignore: 'node_modules/**', dot: true});
const reg = new RegExp("^html[\\\\/]src[\\\\/]public");
files.forEach(file => {
promises.push(new Promise((resolve, reject) => {
const outName = file.replace(reg, 'html/app');
fs.copy(file, outName)
.then(() => {
resolve();
})
.catch(e => {
reject(e.message);
})
;
}));
});
Promise.allSettled(promises)
.finally(() => {
done();
})
;
};
/**
* task: copy & minify
* @listens html/src/img/*
* @exports html/app/img/*
*/
const task_img = async done => {
const png = gulpFilter('**/*.png', {restore: true});
const jpg = gulpFilter('**/*.jpg', {restore: true});
src('./html/src/img/**/*.*', {
allowEmpty: true,
})
.pipe(gulpPlumber())
.pipe(png)
.pipe(sharpOptimizeImages({
png_to_webp: {
quality: 80,
lossless: false,
},
png: {},
}))
.pipe(png.restore)
.pipe(jpg)
.pipe(sharpOptimizeImages({
jpg_to_webp: {
quality: 80,
lossless: false,
},
jpg: {
quality: 80,
mozjpeg: true,
},
}))
.pipe(jpg.restore)
.pipe(dest('./html/app/img'))
;
done();
};
/**
* task: html
* @listens html/src/*
* @exports html/app/*
*/
const task_html = done => {
src('./html/src/**/!(_)*.pug', {
allowEmpty: true,
})
.pipe(gulpPlumber())
.pipe(gulpPug({
locals: {
mode: dirpath.mode,
strictAbsPath: dirpath.strictAbsPath,
rootAbsPath: dirpath.rootAbsPath,
innerPublicPath: dirpath.innerPublicPath,
outerPublicPath: dirpath.outerPublicPath,
version: dirpath.version,
},
filters: {
"php": text => "<?php\r\n" + text + "\r\n?>"
},
}))
.pipe(rename({
extname: '.php',
}))
.pipe(dest('./html/app'))
;
done();
}
/**
* task: css
* @listens html/src/css/*
* @exports html/app/css/*
*/
const task_css = done => {
src('./html/src/**/!(_)*.scss', {
arrowEmpty: true,
sourcemaps: true,
})
.pipe(gulpPlumber())
.pipe(sass({
outputStyle: 'expanded',
}))
.pipe(gulpPostCss([
autoprefixer(),
tailwindcss(),
csso(),
]))
.pipe(dest('./html/app', {
sourcemaps: '.',
}))
;
done();
};
/**
* task: js
* @listens html/src/js/*
* @exports html/app/js/*
*/
import * as esbuild from 'esbuild'
const task_js = async done => {
let files = await glob('html/src/js/**/!(_)*.{js,ts}', {ignore: 'node_modules/**'})
await esbuild.build({
entryPoints: files,
outdir: 'html/app/js',
target: ['es6'],
bundle: true,
minify: true,
sourcemap: true,
logLevel: 'info',
})
done()
};
/**
* task: watch
*/
const task_watch = done => {
watch('./html/src/public/**/*', series(task_copy, task_writeDev));
watch('./html/src/img/**/*', series(task_img, task_writeDev));
watch('./html/src/**/*.pug', series(task_html, task_writeDev));
watch('./html/src/**/*.scss', series(task_css, task_writeDev));
watch('./html/src/**/*.ts', series(task_js, task_writeDev));
done();
};
const task_writeDev = done => {
const time = Date.now()
fs.writeJson('html/app/.gulp-dev', {time: time})
.then(() => {
console.log('dev file is written: ' + time.toString())
})
.catch(() => {
console.log('fail to write a dev file')
})
.finally(() => {
done()
})
}
const task_removeDev = done => {
fs.remove('html/app/.gulp-dev')
.then(() => {
console.log('dev file was removed')
})
.catch(() => {
console.log('fail to remove a dev file')
})
.finally(() => {
done()
})
}
/**
* exports
*/
export default series(
task_removeDev,
task_copy,
task_img,
task_html,
task_css,
task_js,
);
export const dev = series(
task_copy,
task_img,
task_html,
task_css,
task_js,
task_watch,
task_writeDev,
);
WordPressテーマファイルの作成
- html/src/public/wordpress/wp-content/themes/docker-wp/style.css
- html/src/public/wordpress/wp-content/themes/docker-wp/index.php
- html/src/public/wordpress/wp-content/themes/docker-wp/functions.php
- html/src/public/wordpress/wp-content/themes/docker-wp/screenshot.{jpg,png}
style.css
WordPressのテーマ名を定義します。
/*
Theme Name: Docker WP
*/
index.php
WordPressでの表示を拒否して、ドキュメントルートにリダイレクトさせます。
<?
header('Location: /');
exit();
?>
functions.php
中身は空っぽでいいです。
ファイルが存在していることが大事。
screenshot.{jpg,png}
jpeg形式でもpng形式でも問題なし。
画像サイズは1200px × 900pxの、比率は4:3。
ファイルを作る(3)
雛形ファイルを用意します。
- html/src/css/index.scss
- html/src/js/index.ts
- html/src/img/index.png
- html/src/public/favicon.ico
- html/src/index.pug
SCSSとTypeScriptは空ファイルでいいです。
画像やアイコンファイルも適当でOK。
index.pug
Pugファイルですが、bodyタグの終了直前に次のコードを追記して開発モード中のオートリロードを実装します。
下記の8~10行目が該当箇所。
doctype html
html(lang="ja")
head
title 雛形
body
h1 雛形
//- AutoReload
if locals.mode === 'production-local'
script!= "(c=>{let a=0,b=()=>{setTimeout(()=>{fetch('/.gulp-dev',{method:'GET',cache:'no-cache'}).then(r=>r.json()).then(j=>{if(!a){a=j.time}else if(a!==j.time){location.reload()}c.log('dev file watching... '+j.time);b()}).catch(e=>{c.log('catch: ',e.message)})},3000)};b()})(console)"
コマンド
開発モード
npm run dev
WordPressダッシュボードから「サイトを表示」押下、またはhttp://localhost:8880にアクセスすることで表示される。
オートリロード機能有効。
製品モード(ローカルサーバ)
npm run build
オートリロードの監視をしないだけで特に意味はない。
あえて言うなら、オートリロード用のファイルを削除しつつ書き出しを行う処理。
製品モード(レンタルサーバ)
npm run build:xserver
link[rel="canonical"]などのURLを、.envで設定した本番環境向けに変更して出力する。
開発が終わったら開発モードをCtrl + Cで終了して、製品モード(レンタルサーバ)で書き出し。
それが終わったら、html/appフォルダの中身でhtml/app/wordpress/wp-config.phpを除く全ファイル・フォルダをレンタルサーバにアップロードすれば公開・更新処理は完了です。
後で第三者にwordpressフォルダへ侵入されないようアクセス制限を掛けます。
最初に本番環境に展開する場合、wordpressフォルダにアクセスしてセットアップ画面を経る必要があります。
そしてそれ以前にMySQLデータベースを用意する必要もあります。
レンタルサーバのサーバパネルやphpMyAdmin、あるいは直接SSH通信などから「CREATE DATABASE IF NOT EXISTS docker_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;」などとクエリ文を叩いて用意してくださいな。
Wordpressの設定
開発モードを実行。
npm run dev
html/srcフォルダに格納した雛形ファイルの変換と同時に、名称が「Docker WP」になっているWordPressテーマが作成されます。
WordPressダッシュボード 外観 テーマから、「Docker WP」テーマを有効にします。
続けてWordPressダッシュボード 設定 パーマリンクから、パーマリンク構造を「基本」から「投稿名」に変更して「変更を保存」します。
この状態でWordPressダッシュボード サイトを表示と進んで、ちゃんとリダイレクトされて、WordPressのテーマっぽいものが表示されていない代わりに『雛形』と書いたページが表示されればOKです。
適当に雛形ファイルを操作してみて、オートリロードが機能するかも確認しておくとよき。
wordpressフォルダへのアクセス制限
.htaccessのコピー
html/app/wordpress/.htaccessを、html/src/public/wordpress/.htaccessとしてコピペします。
ついでにhtml/src/public/wordpress/.htpasswdを新規作成しておきます。
パスワードの生成
Docker Desktop Containersの一覧から、Nameの所が「web」あるいは「web-(何か数字)」になっている行を探してクリック。
トップメニューのExecをクリックすると、Dockerコンテナ内のターミナルに入ります。
ログインするユーザ名・パスワード・4から31までの中で好きな数字を決めて次のコードをコピペします。
コピペはControl + Vではなく右クリックメニューから「Paste」をクリックなので注意。
htpasswd -nb -B -C 【好きな数字】 【ユーザ名】 【パスワード】
【ユーザ名】:【暗号化されたパスワード】が出力されるので、1行まるごとマウスで選択して、右クリックでコピーして、html/src/public/wordpress/.htpasswdに保存します。
複数ユーザを用意する場合、2行目・3行目へと追記していってください。
必ず行末で改行して、何もない行を最後に置く必要があります(.htpasswdの仕様)
.htaccessの編集
html/src/public/wordpress/.htaccessの1行目に次のコードを挿入します。
<FilesMatch "^¥.ht">
Deny from all
</FilesMatch>
AuthUserFile "/var/www/html/.htpasswd"
AuthName "Please enter your ID and Password."
AuthType BASIC
require valid-user
ここの5行目。
.htpasswdファイルへのパスは通常のドキュメントルートではなく、サーバルートからの指定が必要になります。
メモしておいたDOCUMENT_ROOTが「/var/www/html」ならば変更不要で、その後ろに「/wordpress/.htpasswd」を付け足したものが上の例になります。
違っているならば適宜変更してください。
編集が終われば保存。
開発モード中ならそのまま自動的に反映されてますが、そうでないなら製品モード(ローカルサーバ)(=npm run build)を実行してあげてください。
アクセス制限の機能確認
WordPressダッシュボードにアクセス、もしくは直接http://localhost:8880/wordpress/wp-adminに移動します。
BASIC認証を求められて、ID/PWを入力して、WordPressダッシュボードにアクセスできれば成功。
既にWordPressダッシュボードを開いている場合は、.htaccessを上書き保存した段階でBASIC認証のダイアログウィンドウが開くと思います。
下準備完了&Git登録
制作の下準備は完了です。
Gitに登録するなら、この段階がおすすめ。
制作
制作環境ができたら制作開始です。
開発モードを起動しっぱなしにしておいてください。
.htaccessを拝借
html/src/public/wordpress/.htaccessを、html/src/public/.htaccessにコピペ。
BASIC認証部分を外した上で、次のように編集します。
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
RewriteBaseとRewriteRuleのディレクトリが「/wordpress/」から「/」へと変わっている点に注意。
これでWordPressのパーマリンク設定を無視した自由なルーティングが組めるようになります。
サンプルコード
ファイル構成
- html/
- src/
- css/
- index.scss
- _reset.scss
- _component.scss
- _blogBody.scss
- js/
- index.ts
- img/
- logo.png(省略)
- public/
- css/
- index.php
- js/
- index.php
- img/
- index.php
- wordpress/
- wp-content/
- themes/
- docker-wp/
- style.css
- index.php
- functions.php
- screenshot.jpg(省略)
- docker-wp/
- themes/
- .htaccess
- .htpasswd
- wp-content/
- .htaccess
- apple-touch-icon.png(省略)
- favicon-192.png(省略)
- favicon-512.png(省略)
- favicon.ico(省略)
- favicon.svg(省略)
- manifest.webmanifest
- css/
- index.pug
- _functions.php
- _themeIndex.php
- _themePage.php
- _themePost.php
- _themeCategory.php
- _themeSearch.php
- css/
- src/
ソースファイル
ソースファイル(SCSS)
html/src/css/index.scss
@forward 'reset';
@forward 'component';
@forward 'blogBody';
html/src/css/_reset.scss
// body
body{
@apply grid grid-rows-[auto,1fr,2rem] min-h-screen;
}
// header
#header{
@apply w-screen h-16 bg-stone-900 text-stone-100;
div{
@apply w-[1280px] 2xl:w-[1536px] max-w-full flex flex-row justify-between items-center mx-auto mt-1;
}
picture{
@apply block relative w-14 h-14;
source, img{
@apply block absolute inset-0;
}
}
}
// main
#main{
@apply w-screen mx-auto;
>div{
@apply w-[1280px] 2xl:w-[1536px] max-w-full mx-auto py-3;
}
nav[role="navigation"], section[role="region"]{
@apply grid gap-y-3;
}
}
// search
#search{
input{
@apply w-56;
}
button{
@apply ml-2 px-4 py-1 text-violet-100 bg-violet-700 hover:bg-violet-500 transition-colors duration-300;
}
}
// footer
#footer{
@apply flex justify-center items-center w-full h-8 bg-stone-900 text-stone-100;
}
html/src/css/_component.scss
// body
body{
@apply grid grid-rows-[auto,1fr,2rem] min-h-screen;
}
// header
#header{
@apply w-screen h-16 bg-stone-900 text-stone-100;
div{
@apply w-[1280px] 2xl:w-[1536px] max-w-full flex flex-row justify-between items-center mx-auto mt-1;
}
picture{
@apply block relative w-14 h-14;
source, img{
@apply block absolute inset-0;
}
}
}
// main
#main{
@apply w-screen mx-auto;
>div{
@apply w-[1280px] 2xl:w-[1536px] max-w-full mx-auto py-3;
}
nav[role="navigation"], section[role="region"]{
@apply grid gap-y-3;
}
}
// search
#search{
input{
@apply w-56;
}
button{
@apply ml-2 px-4 py-1 text-violet-100 bg-violet-700 hover:bg-violet-500 transition-colors duration-300;
}
}
// footer
#footer{
@apply flex justify-center items-center w-full h-8 bg-stone-900 text-stone-100;
}
html/src/css/_blogBody.scss
#main{
// separator
hr{
@apply h-12 bg-transparent;
}
// table
table{
@apply border-separate border-spacing-x-2 border-y-2 border-stone-300;
tr:nth-of-type(even){
@apply bg-stone-300;
}
th{
@apply w-32 text-right;
}
}
// a
a{
@apply text-rose-700 hover:text-violet-500 underline decoration-inherit transition-colors duration-300;
}
// empty results
nav[role="navigation"] ul:empty::before{
display: block;
content: "not found...";
}
// search-warning
#search-warning{
--shadow-color: 60deg 2% 50%;
--shadow-elevation-high:
0.2px 0.2px 0.2px hsl(var(--shadow-color) / 0.69),
0.5px 0.4px 0.5px -0.7px hsl(var(--shadow-color) / 0.6),
1.2px 1.1px 1.3px -1.5px hsl(var(--shadow-color) / 0.51),
2.8px 2.5px 3.1px -2.2px hsl(var(--shadow-color) / 0.43),
5.8px 5.3px 6.5px -3px hsl(var(--shadow-color) / 0.34),
10.8px 9.9px 12.1px -3.7px hsl(var(--shadow-color) / 0.25),
18.3px 16.7px 20.4px -4.5px hsl(var(--shadow-color) / 0.16);
box-shadow: var(--shadow-elevation-high);
@apply relative w-fit mt-2 ml-2 px-2 py-1 rounded-md text-amber-100 bg-amber-700;
&::before{
@apply absolute content-[""] block w-0 h-0 left-2 -top-2 border-b-[0.5rem] border-x-[0.25rem] border-x-transparent border-b-amber-700;
}
&:empty{
@apply hidden;
}
}
}
ソースファイル(TypeScript)
html/src/js/index.ts
(d => {
const setAlert = (message: string) => {
(d.querySelector('#search-warning') as HTMLElement)!.innerText = message
}
d.addEventListener('DOMContentLoaded', () => {
d.querySelector('form#search')?.addEventListener('submit', e => {
e.stopPropagation()
setAlert('')
let value = '', input = (e.target as HTMLElement).querySelector('input')
if(input){
value = input.value
}
value = value.replace(/ /g, ' ')
value = value.replace(/\s+/g, ' ')
value = value.trim()
if(!value){
setAlert('検索ワードを入力してください')
e.preventDefault()
return false
}
if(/[\t\r\n\"\'\`\/\\\\]/.test(value)){
setAlert('禁止文字が含まれています')
e.preventDefault()
return false
}
let split = value.split(' '), positive: Array<string> = [], negative: Array<string> = []
for(let i = 0, l = split.length; i < l; i ++){
if(split[i].charAt(0) === '-'){
negative.push(split[i])
}else{
positive.push(split[i])
}
}
if(positive.length + negative.length === 0){
setAlert('有効な検索ワードが存在しません')
e.preventDefault()
return false
}
if(positive.length === 0){
setAlert('除外検索以外の検索ワードも指定してください')
e.preventDefault()
return false
}
})
})
})(document)
ソースファイル(既存フォルダの閲覧禁止)
html/src/public/css/index.php
html/src/public/js/index.php
html/src/public/img/index.php
フォルダの中身を見られないようにするためリダイレクト。
<?php
header('Location: /');
exit();
?>
ソースファイル(WordPressテーマ)
再掲。
html/src/public/wordpress/wp-content/themes/docker-wp/style.css
/*
Theme Name: Docker WP
*/
html/src/public/wordpress/wp-content/themes/docker-wp/index.php
<?
header('Location: /');
exit();
?>
html/src/public/wordpress/wp-content/themes/docker-wp/functions.php
空ファイル。
ソースファイル(WordPressフォルダへのアクセス制限)
html/src/public/wordpress/.htaccess
<FilesMatch "^¥.ht">
Deny from all
</FilesMatch>
AuthUserFile /var/www/html/wordpress/.htpasswd
AuthName "Please enter your ID and Password."
AuthType BASIC
require valid-user
# BEGIN WordPress
# "BEGIN WordPress" から "END WordPress" までのディレクティブ (行) は
# 動的に生成され、WordPress フィルターによってのみ修正が可能です。
# これらのマーカー間にあるディレクティブへのいかなる変更も上書きされてしまいます。
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /wordpress/
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /wordpress/index.php [L]
</IfModule>
# END WordPress
html/src/public/wordpress/.htpasswd
生成したIDとパスワードによって中身は変わります。
必ず最後で改行して、空の行を開けておくこと。
*******:$2y$**$*******
ソースファイル(その他のpublic)
html/src/public/manifest.webmanifest
favicon設定。
{
"icons": [
{"src": "favicon-192.png", "type": "image/png", "sizes": "192x192"},
{"src": "favicon-512.png", "type": "image/png", "sizes": "512x512"}
]
}
html/src/public/.htaccess
ヘッドレスCMSとしての.htaccessファイル。
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
ソースファイル(CMS)
html/src/index.pug
include _functions.php
include _themePost.php
include _themePage.php
include _themeCategory.php
include _themeSearch.php
include _themeIndex.php
:php
/**
* URLを分析して表示すべきテーマを決定する
* @param string|null $_SERVER['REDIRECT_URL'] ファイルまたはフォルダが存在しなかった場合のREQUEST_URI
* @return string[] テーマ識別子,引数(なければ空文字)
* @throws HttpStatus404 存在しないファイルを指定した場合は404エラーを返す
*/
if(isset($_POST['search']) && ('' !== trim(preg_replace('/[\t\r\n\"\'\`\/\\\\]/', '', str_replace(' ', ' ', $_POST['search']))))){
$query = trim(preg_replace('/[\t\r\n\"\'\`\/\\\\]/', '', str_replace(' ', ' ', $_POST['search'])));
$query = preg_replace('/\s+/', ' ', $query);
$query = explode(' ', $query);
$positive = [];
$negative = [];
for($i = 0, $l = count($query); $i < $l; $i ++){
if(substr($query[$i], 0, 1) === '-'){
$negative[] = mb_substr($query[$i], 1);
}else{
$positive[] = $query[$i];
}
}
if(count($positive) === 0){
$theme = ['index', ''];
}else{
$theme = ['search', $positive, $negative];
}
}else if(isset($_SERVER['REDIRECT_URL'])){
$theme = $filename = explode('/', $_SERVER['REDIRECT_URL']);
$filename = array_pop($filename);
if(strpos($filename, '.') >= 0)
if(preg_match('/^.+\..+$/', $filename)){
header('HTTP/1.1 404 Not Found');
exit();
}else{
while((count($theme) > 0) && ($theme[0] === '')){
array_shift($theme);
}
if(!isset($theme[1])){
$theme[1] = '';
}
}
}else{
$theme = ['index', ''];
}
/**
* テーマ識別子に従ってHTML文字列を取得する
* @param string $theme[0] テーマ識別子
* @todo 取得したHTML文字列を標準出力する
*/
switch($theme[0]){
/** @todo 検索 */
case 'search':
if(!isset($_POST['search'])){
list($head, $body) = [null, null];
}else{
list($head, $body) = themeSearch($theme[1], $theme[2]);
}
break;
/** @todo カテゴリ */
case 'category':
list($head, $body) = themeCategory($theme[1]);
break;
/** @todo 投稿 */
case 'post':
list($head, $body) = themePost($theme[1]);
break;
/** @todo サイトトップ、あるいは存在しないフォルダのフォールバック */
case 'index':
list($head, $body) = themeIndex();
break;
/** @todo 固定ページ */
default:
list($head, $body) = themePage($theme[0]);
break;
}
/** @todo 投稿・固定ページ等で存在しないスラッグを指定したためサイトトップ表記に強制変更 */
if($body === null){
list($head, $body) = themeIndex();
}
/**
* PHP処理ここまで
*/
doctype html
html(lang='ja')
head
:php
echo $head;
body
link(rel="stylesheet", href="/css/index.css")
script(src="/js/index.js")
<link href="https://fonts.googleapis.com/css2?family=Kiwi+Maru:wght@300&display=swap" rel="stylesheet">
:php
if($isSeoOutout){
echo "<noscript><iframe src='https://www.googletagmanager.com/ns.html?id={$googleGTM}' width='0' height='0' style='display:none;visibility:hidden'></iframe></noscript>";
}
:php
echo $body;
//- auto reload
if locals.mode === 'production-local'
script!= "(c=>{let a=0,b=()=>{setTimeout(()=>{fetch('/.gulp-dev',{method:'GET',cache:'no-cache'}).then(r=>r.json()).then(j=>{if(!a){a=j.time}else if(a!==j.time){location.reload()}c.log('dev file watching... '+j.time);b()}).catch(e=>{c.log('catch: ',e.message)})},3000)};b()})(console)"
html/src/_functions.php
<?php
/**
* 開始:_functions.php
*/
require_once('wordpress/wp-load.php');
/** @param string |https?://domain([:]port)?| */
$baseUrl = 'http://localhost:8880';
/** @param boolean */
$isSeoOutout = false;
$googleG = 'G-*******';
$googleGTM = 'GTM-*******';
/**
* wpdb->get_resultsでデータを取得
* @todo SELECT文でない場合は、"必ず"データアクセスを行わずに空配列を返します
* @param string $sql クエリ文
* @return mixed 取得内容
*/
function wq($sql = ''){
global $wpdb;
$sqlCheck = trim(strtolower($sql));
if(mb_substr($sqlCheck, 0, 7) !== 'select '){
return [];
}
$res = $wpdb->get_results($sql);
return $res;
}
/**
* 危険文字を削除する
* @param string $str 処理したい文字列
* @return string 処理後の文字列
*/
function rm($str){
$str = preg_replace('/[\t\r\n\s\'\"\/\\\\<>&]/', '', $str);
return $str;
}
/**
* \t\r\nなど、ホワイトスペースの除去
* @param string $str 処理したい文字列
* @return string 処理後の文字列
*/
function trn($str){
$str = trim(preg_replace('/[\t\r\n\s]+/', ' ', $str));
$str = preg_replace('/>\s*</', '><', $str);
return $str;
}
/**
* htmlspecialcharsのラッパー
* @param string $str 処理したい文字列
* @return string 処理後の文字列
*/
function esc($str){
$str = htmlspecialchars($str, ENT_QUOTES | ENT_HTML5);
return $str;
}
/**
* 日付のフォーマット
* @param string $str Y-m-d H:i:s
* @return string Y-m-d
*/
function df($datetime){
$date = new DateTime($datetime);
return $date->format('Y-m-d');
}
?>
html/src/_themeIndex.php
<?php
/**
* 開始:_themeIndex.php
*/
/**
* テーマ識別子「index」に従ったHTML文字列を生成する
* @todo index: サイトトップ、あるいは存在しないフォルダのフォールバック
* @return string[] HTML文字列
* @return string HEADタグ内のHTML文字列
* @return string BODYタグ内のHTML文字列
*/
function themeIndex(){
global $baseUrl;
global $isSeoOutout;
global $googleG;
global $googleGTM;
// head
$head = [];
if($isSeoOutout){
// Google tag (gtag.js)
$head[] = "<script async src='https://www.googletagmanager.com/gtag/js?id={$googleG}'></script>";
$head[] = "<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','{$googleG}');</script>";
// Google Tag Manager
$head[] = "<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{$googleGTM}');</script>";
}
$head[] = trn
("
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<base href='/'>
");
$head[] = trn
("
<title>WPTEST</title>
<meta name='description' content='WPTEST COMMON DESCRIPTION'>
<meta property='og:url' content='{$baseUrl}'>
<meta property='og:title' content='WPTEST'>
<meta property='og:description' content='WPTEST COMMON DESCRIPTION'>
<meta property='og:site_name' content='WPTEST'>
<meta property='og:image' content='{$baseUrl}/favicon-512.png'>
<link rel='index' content='{$baseUrl}'>
<link rel='canonical' content='{$baseUrl}'>
");
$head[] = trn
("
<meta name='robots' content='index,follow'>
<meta name='rating' content='general'>
<meta name='google' content='notranslate'>
<meta name='referrer' content='no-referrer'>
<meta name='format-detection' content='telephone=no'>
<meta property='og:type' content='article'>
<meta name='twitter:card' content='summary'>
<meta name='twitter:dnt' content='on'>
<meta name='ICBM' content='36.*******, 139.*******'>
<meta name='geo.position' content='36.*******;139.*******'>
<meta name='geo.region' content='JP'>
<meta name='geo.placename' content='**** city, **** pref., Japan'>
");
$head[] = trn
("
<link rel='icon' href='{$baseUrl}/favicon.ico' sizes='any'>
<link rel='icon' href='{$baseUrl}/favicon.svg' type='image/svg+xml'>
<link rel='mask-icon' href='{$baseUrl}/favicon.svg' color='#000'>
<link rel='apple-touch-icon' href='{$baseUrl}/apple-touch-icon.png'>
<link rel='manifest' href='{$baseUrl}/manifest.webmanifest'>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
");
// get data
$sql = trn
("
SELECT
post_name,
post_title
FROM
wp_posts
WHERE
post_status = 'publish' AND
post_type = 'page'
ORDER BY
ID DESC
LIMIT 5
");
$pages = wq($sql);
$sql = trn
("
SELECT
post_name,
post_date,
post_title
FROM
wp_posts
WHERE
post_status = 'publish' AND
post_type = 'post'
ORDER BY
ID DESC
LIMIT 5
");
$posts = wq($sql);
$sql = trn
("
SELECT
t.name AS term_name,
t.slug AS term_slug,
tt.count AS post_count
FROM
wp_terms t
INNER JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id
WHERE
tt.taxonomy = 'category' AND
tt.count > 0
ORDER BY
tt.count DESC,
t.term_id DESC
LIMIT 5
");
$terms = wq($sql);
// body
$body = [];
$body[] = trn
("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>index</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<div><dfn>投稿 (最新5件)</dfn><ul>";
for($i = 0, $l = count($posts); $i < $l; $i ++){
$body[] = "<li><a href='/post/" . $posts[$i]->post_name . "'>" . esc($posts[$i]->post_title) . "</a><small>(" . df($posts[$i]->post_date) . ")</small></li>";
}
$body[] = "</ul></div>";
$body[] = "<div><dfn>固定ページ (最新5件)</dfn><ul>";
for($i = 0, $l = count($pages); $i < $l; $i ++){
$body[] = "<li><a href='/" . $pages[$i]->post_name . "'>" . esc($pages[$i]->post_title) . "</a></li>";
}
$body[] = "</ul></div>";
$body[] = "<div><dfn>カテゴリ (最大5件)</dfn><ul>";
for($i = 0, $l = count($terms); $i < $l; $i ++){
$body[] = "<li><a href='/category/" . $terms[$i]->term_slug . "'>" . esc($terms[$i]->term_name) . "</a><small>(" . $terms[$i]->post_count. ")</small></li>";
}
$body[] = "</ul></div>";
$body[] = trn
("
<form id='search' role='search' action='/search/' method='post'>
<label>
<input type='search' name='search' spellcheck='false' placeholder='検索ワードを入力'>
<button>検索</button>
</label>
<p id='search-warning'></p>
</form>
");
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return [
implode('', $head),
implode('', $body),
];
}
?>
html/src/_themePage.php
<?php
/**
* 開始:_themePage.php
*/
/**
* テーマ識別子「page」に従ったHTML文字列を生成する
* @todo post: 固定ページ
* @param string $arg 引数(固定ページのスラッグ)
* @return string[] HTML文字列
* @return string|null HEADタグ内のHTML文字列(存在しないスラッグだった場合はnull)
* @return string|null BODYタグ内のHTML文字列(存在しないスラッグだった場合はnull)
*/
function themePage($arg = ''){
global $baseUrl;
global $isSeoOutout;
global $googleG;
global $googleGTM;
// get data
$slug = rawurlencode(rm($arg));
$sql = trn
("
SELECT
ID as post_id,
post_name,
post_date,
post_modified,
post_title,
post_content,
post_excerpt
FROM
wp_posts
WHERE
post_status = 'publish' AND
post_type = 'page' AND
post_name = '{$slug}'
ORDER BY
ID DESC
LIMIT 1
");
$pages = wq($sql);
if(count($pages) === 0){
return [null, null];
}
$title = $pages[0]->post_title;
$titleEsc = esc($title);
$excerpt = $pages[0]->post_excerpt;
if($excerpt === ''){
$excerpt = $pages[0]->post_content;
$excerpt = preg_replace('/(<.*?>|[\t\r\n\s])/', '', $excerpt);
$excerpt = mb_substr($excerpt, 0, 80);
}
$excerpt = esc($excerpt);
// head
$head = [];
if($isSeoOutout){
// Google tag (gtag.js)
$head[] = "<script async src='https://www.googletagmanager.com/gtag/js?id={$googleG}'></script>";
$head[] = "<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','{$googleG}');</script>";
// Google Tag Manager
$head[] = "<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{$googleGTM}');</script>";
}
$head[] = trn
("
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<base href='/'>
");
$head[] = trn
("
<title>{$titleEsc} - WPTEST</title>
<meta name='description' content='{$excerpt}'>
<meta property='og:url' content='{$baseUrl}/{$slug}'>
<meta property='og:title' content='{$titleEsc} - WPTEST'>
<meta property='og:description' content='{$excerpt}'>
<meta property='og:site_name' content='WPTEST'>
<meta property='og:image' content='{$baseUrl}/favicon-512.png'>
<link rel='index' content='{$baseUrl}'>
<link rel='canonical' content='{$baseUrl}/{$slug}'>
");
$head[] = trn
("
<meta name='robots' content='index,follow'>
<meta name='rating' content='general'>
<meta name='google' content='notranslate'>
<meta name='referrer' content='no-referrer'>
<meta name='format-detection' content='telephone=no'>
<meta property='og:type' content='article'>
<meta name='twitter:card' content='summary'>
<meta name='twitter:dnt' content='on'>
<meta name='ICBM' content='36.*******, 139.*******'>
<meta name='geo.position' content='36.*******;139.*******'>
<meta name='geo.region' content='JP'>
<meta name='geo.placename' content='**** city, **** pref., Japan'>
");
$head[] = trn
("
<link rel='icon' href='{$baseUrl}/favicon.ico' sizes='any'>
<link rel='icon' href='{$baseUrl}/favicon.svg' type='image/svg+xml'>
<link rel='mask-icon' href='{$baseUrl}/favicon.svg' color='#000'>
<link rel='apple-touch-icon' href='{$baseUrl}/apple-touch-icon.png'>
<link rel='manifest' href='{$baseUrl}/manifest.webmanifest'>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
");
// body
$body = [];
$body[] = trn
("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>{$titleEsc} - page</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<section role='region' aria-label='メインコンテンツ'>";
$body[] = "<table><tbody>";
foreach($pages[0] as $k => $v){
$body[] = "<tr><th>{$k}</th><td>{$v}</td></tr>";
}
$body[] = "</tbody></table>";
$body[] = "</section>";
$body[] = "<hr>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<a href='/' class='block w-fit mx-auto text-center'>サイトトップに戻る</a>";
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return [
implode('', $head),
implode('', $body),
];
}
?>
html/src/_themePost.php
<?php
/**
* 開始:_themePost.php
*/
/**
* テーマ識別子「post」に従ったHTML文字列を生成する
* @todo post: 投稿
* @param string $arg 引数(空文字の場合は投稿一覧、そうでない場合は投稿のスラッグ)
* @return string[] HTML文字列
* @return string|null HEADタグ内のHTML文字列(存在しないスラッグだった場合はnull)
* @return string|null BODYタグ内のHTML文字列(存在しないスラッグだった場合はnull)
*/
function themePost($arg = ''){
global $baseUrl;
global $isSeoOutout;
global $googleG;
global $googleGTM;
$slug = rawurlencode(rm($arg));
/**
* 投稿の一覧を返す
* @return mixed[]
* @return string|null return.body BODYタグ内のHTML文字列(一件もなかった場合はnull)
* @return string return.title TITLEタグ
* @return string return.titleEsc エスケープ済みTITLEタグ
* @return string return.excerpt DESCRIPTION
*/
function post_list(){
$sql = trn
("
SELECT
post_name,
post_date,
post_title
FROM
wp_posts
WHERE
post_status = 'publish' AND
post_type = 'post'
ORDER BY
ID DESC
");
$posts = wq($sql);
if(count($posts) === 0){
return ['body' => null, 'title' => '投稿一覧', 'titleEsc' => esc('投稿一覧'), 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
// body
$body = [];
$body[] = trn
("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>投稿一覧 - post</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<div><dfn>投稿</dfn><ul>";
for($i = 0, $l = count($posts); $i < $l; $i ++){
$body[] = "<li><a href='/post/" . $posts[$i]->post_name . "'>" . esc($posts[$i]->post_title) . "</a><small>(" . df($posts[$i]->post_date) . ")</small></li>";
}
$body[] = "</ul></div>";
$body[] = "</nav>";
$body[] = "<hr>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<a href='/' class='block w-fit mx-auto text-center'>サイトトップに戻る</a>";
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return ['body' => implode('', $body), 'title' => '投稿一覧', 'titleEsc' => esc('投稿一覧'), 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
/**
* 指定したスラッグを持つ投稿を返す
* @param string $arg 投稿のスラッグ
* @return mixed[]
* @return string|null BODYタグ内のHTML文字列(スラッグに対応する記事がない場合はnull)
* @return string return.title TITLEタグ
* @return string return.titleEsc エスケープ済みTITLEタグ
* @return string return.excerpt DESCRIPTION
*/
function post_specified($slug){
$sql = trn
("
SELECT
ID as post_id,
post_name,
post_date,
post_modified,
post_title,
post_content,
post_excerpt
FROM
wp_posts
WHERE
post_status = 'publish' AND
post_type = 'post' AND
post_name = '{$slug}'
ORDER BY
ID DESC
LIMIT 1
");
$posts = wq($sql);
if(count($posts) === 0){
return ['body' => null, 'title' => '投稿', 'titleEsc' => esc('投稿'), 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
$title = $posts[0]->post_title;
$titleEsc = esc($title);
$excerpt = $posts[0]->post_excerpt;
if($excerpt === ''){
$excerpt = $posts[0]->post_content;
$excerpt = preg_replace('/(<.*?>|[\t\r\n\s])/', '', $excerpt);
$excerpt = mb_substr($excerpt, 0, 80);
}
$excerpt = esc($excerpt);
// body
$body = [];
$body[] = trn
("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>{$titleEsc} - post</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<section role='region' aria-label='メインコンテンツ'>";
$body[] = "<table><tbody>";
foreach($posts[0] as $k => $v){
$body[] = "<tr><th>{$k}</th><td>{$v}</td></tr>";
}
$body[] = "</tbody></table>";
$body[] = "</section>";
$body[] = "<hr>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<a href='/post/' class='block w-fit mx-auto text-center'>投稿一覧</a>";
$body[] = "<a href='/' class='block w-fit mx-auto text-center'>サイトトップに戻る</a>";
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return ['body' => implode('', $body), 'title' => $title, 'titleEsc' => $titleEsc, 'excerpt' => $excerpt];
}
// スラッグが空文字か否かで処理分岐
if($slug === ''){
$ret = post_list();
}else{
$ret = post_specified($slug);
}
if($ret['body'] === null){
return [null, null];
}
// head
$head = [];
if($isSeoOutout){
// Google tag (gtag.js)
$head[] = "<script async src='https://www.googletagmanager.com/gtag/js?id={$googleG}'></script>";
$head[] = "<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','{$googleG}');</script>";
// Google Tag Manager
$head[] = "<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{$googleGTM}');</script>";
}
$head[] = trn
("
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<base href='/'>
");
$head[] = trn
("
<title>{$ret["titleEsc"]} - WPTEST</title>
<meta name='description' content='{$ret["excerpt"]}'>
<meta property='og:url' content='{$baseUrl}/post/{$slug}'>
<meta property='og:title' content='{$ret["titleEsc"]} - WPTEST'>
<meta property='og:description' content='{$ret["excerpt"]}'>
<meta property='og:site_name' content='WPTEST'>
<meta property='og:image' content='{$baseUrl}/favicon-512.png'>
<link rel='index' content='{$baseUrl}'>
<link rel='canonical' content='{$baseUrl}/post/{$slug}'>
");
$head[] = trn
("
<meta name='robots' content='index,follow'>
<meta name='rating' content='general'>
<meta name='google' content='notranslate'>
<meta name='referrer' content='no-referrer'>
<meta name='format-detection' content='telephone=no'>
<meta property='og:type' content='article'>
<meta name='twitter:card' content='summary'>
<meta name='twitter:dnt' content='on'>
<meta name='ICBM' content='36.*******, 139.*******'>
<meta name='geo.position' content='36.*******;139.*******'>
<meta name='geo.region' content='JP'>
<meta name='geo.placename' content='**** city, **** pref., Japan'>
");
$head[] = trn
("
<link rel='icon' href='{$baseUrl}/favicon.ico' sizes='any'>
<link rel='icon' href='{$baseUrl}/favicon.svg' type='image/svg+xml'>
<link rel='mask-icon' href='{$baseUrl}/favicon.svg' color='#000'>
<link rel='apple-touch-icon' href='{$baseUrl}/apple-touch-icon.png'>
<link rel='manifest' href='{$baseUrl}/manifest.webmanifest'>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
");
return [
implode('', $head),
$ret['body'],
];
}
?>
html/src/_themeCategory.php
<?php
/**
* 開始:_themeCategory.php
*/
/**
* テーマ識別子「category」に従ったHTML文字列を生成する
* @todo category: カテゴリ
* @param string $arg 引数(空文字の場合はカテゴリ一覧、そうでない場合はカテゴリに含まれる投稿のスラッグ)
* @return string[] HTML文字列
* @return string|null HEADタグ内のHTML文字列(存在しないスラッグだった場合はnull)
*/
function themeCategory($arg = ''){
global $baseUrl;
global $isSeoOutout;
global $googleG;
global $googleGTM;
$slug = rawurlencode(rm($arg));
/**
* カテゴリの一覧を返す
* @return mixed[]
* @return string|null return.body BODYタグ内のHTML文字列(一件もなかった場合はnull)
* @return string return.title TITLEタグ
* @return string return.titleEsc エスケープ済みTITLEタグ
* @return string return.excerpt DESCRIPTION
*/
function category_list(){
$sql = trn
("
SELECT
t.name AS term_name,
t.slug AS term_slug,
tt.count AS post_count
FROM
wp_terms t
INNER JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id
WHERE
tt.taxonomy = 'category' AND
tt.count > 0
ORDER BY
tt.count DESC,
t.term_id DESC
");
$terms = wq($sql);
if(count($terms) === 0){
return ['body' => null, 'title' => 'カテゴリ一覧', 'titleEsc' => esc('カテゴリ一覧'), 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
// body
$body = [];
$body[] = trn
("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>カテゴリ一覧 - category</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<div><dfn>カテゴリ</dfn><ul>";
for($i = 0, $l = count($terms); $i < $l; $i ++){
$body[] = "<li><a href='/category/" . $terms[$i]->term_slug . "'>" . esc($terms[$i]->term_name) . "</a><small>(" . $terms[$i]->post_count . ")</small></li>";
}
$body[] = "</ul></div>";
$body[] = "</nav>";
$body[] = "<hr>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<a href='/' class='block w-fit mx-auto text-center'>サイトトップに戻る</a>";
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return ['body' => implode('', $body), 'title' => 'カテゴリ一覧', 'titleEsc' => esc('カテゴリ一覧'), 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
/**
* 指定したスラッグを持つカテゴリに含まれる投稿を返す
* @param string $arg 投稿のスラッグ
* @return mixed[]
* @return string|null BODYタグ内のHTML文字列(スラッグに対応する記事がない場合はnull)
* @return string return.title TITLEタグ
* @return string return.titleEsc エスケープ済みTITLEタグ
* @return string return.excerpt DESCRIPTION
*/
function category_specified($slug){
$sql = trn
("
SELECT
p.post_name,
p.post_date,
p.post_title,
t.name AS term_name
FROM
wp_posts p
INNER JOIN wp_term_relationships tr ON p.ID = tr.object_id
INNER JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN wp_terms t ON tt.term_id = t.term_id
WHERE
p.post_status = 'publish' AND
p.post_type = 'post' AND
tt.taxonomy = 'category' AND
t.slug = '{$slug}'
ORDER BY
p.ID DESC
");
$posts = wq($sql);
if(count($posts) === 0){
return ['body' => null, 'title' => 'カテゴリ', 'titleEsc' => esc('カテゴリ'), 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
$title = $posts[0]->term_name;
$titleEsc = esc($title);
// body
$body = [];
$body[] = trn
("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>{$titleEsc} - category</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<div><dfn>投稿</dfn><ul>";
for($i = 0, $l = count($posts); $i < $l; $i ++){
$body[] = "<li><a href='/post/" . $posts[$i]->post_name . "'>" . esc($posts[$i]->post_title) . "</a><small>(" . df($posts[$i]->post_date) . ")</small></li>";
}
$body[] = "</ul></div>";
$body[] = "</nav>";
$body[] = "<hr>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<a href='/category/' class='block w-fit mx-auto text-center'>カテゴリ一覧</a>";
$body[] = "<a href='/' class='block w-fit mx-auto text-center'>サイトトップに戻る</a>";
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return ['body' => implode('', $body), 'title' => $title, 'titleEsc' => $titleEsc, 'excerpt' => 'WPTEST COMMON DESCRIPTION'];
}
// スラッグが空文字か否かで処理分岐
if($slug === ''){
$ret = category_list();
}else{
$ret = category_specified($slug);
}
if($ret['body'] === null){
return [null, null];
}
// head
$head = [];
if($isSeoOutout){
// Google tag (gtag.js)
$head[] = "<script async src='https://www.googletagmanager.com/gtag/js?id={$googleG}'></script>";
$head[] = "<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','{$googleG}');</script>";
// Google Tag Manager
$head[] = "<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{$googleGTM}');</script>";
}
$head[] = trn
("
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<base href='/'>
");
$head[] = trn
("
<title>{$ret["titleEsc"]} - WPTEST</title>
<meta name='description' content='{$ret["excerpt"]}'>
<meta property='og:url' content='{$baseUrl}/category/{$slug}'>
<meta property='og:title' content='{$ret["titleEsc"]} - WPTEST'>
<meta property='og:description' content='{$ret["excerpt"]}'>
<meta property='og:site_name' content='WPTEST'>
<meta property='og:image' content='{$baseUrl}/favicon-512.png'>
<link rel='index' content='{$baseUrl}'>
<link rel='canonical' content='{$baseUrl}/category/{$slug}'>
");
$head[] = trn
("
<meta name='robots' content='index,follow'>
<meta name='rating' content='general'>
<meta name='google' content='notranslate'>
<meta name='referrer' content='no-referrer'>
<meta name='format-detection' content='telephone=no'>
<meta property='og:type' content='article'>
<meta name='twitter:card' content='summary'>
<meta name='twitter:dnt' content='on'>
<meta name='ICBM' content='36.*******, 139.*******'>
<meta name='geo.position' content='36.*******;139.*******'>
<meta name='geo.region' content='JP'>
<meta name='geo.placename' content='**** city, **** pref., Japan'>
");
$head[] = trn
("
<link rel='icon' href='{$baseUrl}/favicon.ico' sizes='any'>
<link rel='icon' href='{$baseUrl}/favicon.svg' type='image/svg+xml'>
<link rel='mask-icon' href='{$baseUrl}/favicon.svg' color='#000'>
<link rel='apple-touch-icon' href='{$baseUrl}/apple-touch-icon.png'>
<link rel='manifest' href='{$baseUrl}/manifest.webmanifest'>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
");
return [
implode('', $head),
$ret['body'],
];
}
?>
html/src/_themeSearch.php
<?php
/**
* 開始:_themeSearch.php
*/
/**
* テーマ識別子「search」に従ったHTML文字列を生成する
* @todo search: 検索
* @param string[] $positive ポジティブ検索ワード
* @param string[] $negative ネガティブ検索ワード
* @return string[] HTML文字列
* @return string HEADタグ内のHTML文字列
* @return string BODYタグ内のHTML文字列
*/
function themeSearch($positive, $negative){
global $baseUrl;
global $isSeoOutout;
global $googleG;
global $googleGTM;
// get data
$posi = [];
for($i = 0, $l = count($positive); $i < $l; $i ++){
$posi[] = "INNER JOIN (SELECT ID, CEILING((LENGTH(CONCAT(post_title, post_content)) - LENGTH(REPLACE(UPPER(CONCAT(post_title, post_content)), UPPER('{$positive[$i]}'), ''))) / LENGTH('{$positive[$i]}')) AS cnt FROM wp_posts WHERE post_status = 'publish' AND post_type = 'post') positive_{$i} ON p.ID = positive_{$i}.ID";
}
$posi = implode(' ', $posi);
$nega = [];
for($i = 0, $l = count($negative); $i < $l; $i ++){
$nega[] = "INNER JOIN (SELECT ID, CEILING((LENGTH(CONCAT(post_title, post_content)) - LENGTH(REPLACE(UPPER(CONCAT(post_title, post_content)), UPPER('{$negative[$i]}'), ''))) / LENGTH('{$negative[$i]}')) AS cnt FROM wp_posts WHERE post_status = 'publish' AND post_type = 'post') negative_{$i} ON p.ID = negative_{$i}.ID";
}
$nega = implode(' ', $nega);
$sql = "
SELECT
p.post_name,
p.post_date,
p.post_title
FROM
wp_posts p
{$posi}
{$nega}
WHERE
";
$tmp = [];
for($i = 0, $l = count($positive); $i < $l; $i ++){
$tmp[] = " (!positive_{$i}.cnt XOR 1)";
}
if(count($tmp) > 0){
$sql .= implode(' + ', $tmp) . " + 0 = {$l} AND";
}
$tmp = [];
for($i = 0, $l = count($negative); $i < $l; $i ++){
$tmp[] = " (!negative_{$i}.cnt XOR 1)";
}
if(count($tmp) > 0){
$sql .= implode(' + ', $tmp) . ' + 0 = 0 AND';
}
$sql .= "
p.post_status = 'publish' AND
p.post_type = 'post'
GROUP BY p.ID
ORDER BY
";
$tmp = [];
for($i = 0, $l = count($positive); $i < $l; $i ++){
$tmp[] = "positive_{$i}.cnt";
}
if(count($tmp) > 0){
$sql .= ' (' . implode(' + ', $tmp) . ") DESC,";
}
$sql .= "
p.post_date DESC,
p.ID DESC
";
//$sql .= ' LIMIT' . ($pageLen * $pageNum) . ', ' . $pageLen;
$sql = trn($sql);
$results = wq($sql);
$title = '検索: ' . implode(' ', $positive);
for($i = 0, $l = count($negative); $i < $l; $i ++){
$title.= " -{$negative[$i]}";
}
$titleEsc = esc($title);
$excerpt = 'WPTEST COMMON DESCRIPTION';
// head
$head = [];
if($isSeoOutout){
// Google tag (gtag.js)
$head[] = "<script async src='https://www.googletagmanager.com/gtag/js?id={$googleG}'></script>";
$head[] = "<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','{$googleG}');</script>";
// Google Tag Manager
$head[] = "<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{$googleGTM}');</script>";
}
$head[] = trn
("
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<base href='/'>
");
$head[] = trn
("
<title>{$titleEsc} - WPTEST</title>
<meta name='description' content='{$excerpt}'>
<meta property='og:url' content='{$baseUrl}/search'>
<meta property='og:title' content='{$titleEsc} - WPTEST'>
<meta property='og:description' content='{$excerpt}'>
<meta property='og:site_name' content='WPTEST'>
<meta property='og:image' content='{$baseUrl}/favicon-512.png'>
<link rel='index' content='{$baseUrl}'>
<link rel='canonical' content='{$baseUrl}/search'>
");
$head[] = trn
("
<meta name='robots' content='index,follow'>
<meta name='rating' content='general'>
<meta name='google' content='notranslate'>
<meta name='referrer' content='no-referrer'>
<meta name='format-detection' content='telephone=no'>
<meta property='og:type' content='article'>
<meta name='twitter:card' content='summary'>
<meta name='twitter:dnt' content='on'>
<meta name='ICBM' content='36.*******, 139.*******'>
<meta name='geo.position' content='36.*******;139.*******'>
<meta name='geo.region' content='JP'>
<meta name='geo.placename' content='**** city, **** pref., Japan'>
");
$head[] = trn
("
<link rel='icon' href='{$baseUrl}/favicon.ico' sizes='any'>
<link rel='icon' href='{$baseUrl}/favicon.svg' type='image/svg+xml'>
<link rel='mask-icon' href='{$baseUrl}/favicon.svg' color='#000'>
<link rel='apple-touch-icon' href='{$baseUrl}/apple-touch-icon.png'>
<link rel='manifest' href='{$baseUrl}/manifest.webmanifest'>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
");
// body
$body = [];
$body[] = trn("
<header id='header' role='banner'>
<div>
<h1>
<a href='/'>
<picture>
<source srcset='/img/logo.webp' type='image/webp'>
<img src='/img/logo.png' alt='Logo'>
</picture>
</a>
</h1>
<h2>{$titleEsc} - search</h2>
</div>
</header>
");
$body[] = "<main id='main' role='main'><div>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<div><dfn>検索結果</dfn><ul>";
for($i = 0, $l = count($results); $i < $l; $i ++){
$body[] = "<li><a href='/post/" . $results[$i]->post_name . "'>" . esc($results[$i]->post_title) . "</a><small>(" . df($results[$i]->post_date) . ")</small></li>";
}
$body[] = "</ul></div>";
$body[] = "</nav>";
$body[] = "<hr>";
$body[] = "<nav role='navigation' aria-label='links'>";
$body[] = "<a href='/' class='block w-fit mx-auto text-center'>サイトトップに戻る</a>";
$body[] = "</nav>";
$body[] = "</div></main>";
$body[] = trn
("
<footer id='footer' role='contentinfo'>
<h3>©2000 nanika.</h3>
</footer>
");
return [
implode('', $head),
implode('', $body),
];
}
?>