個人開発のAfter 6 Streetには、記事ごとのURLも、普通のHTMLで読める全文もありました。
ただし、全文の置き場所は、<noscript>の中でした。
本を開く演出はJavaScriptで動かす。JavaScriptが無効なら、代わりに全文を出す。この分担だと、JavaScriptを有効にしたまま演出の読み込みが止まった場合に、通常の本文表示へ逃がしにくくなります。
本屋にたとえると、読書灯は用意してあるのに、店のブレーカーを落としたときしか使えない。読者にブラウザーの設定まで触ってもらうのは、少し入店条件が厳しい。
検索結果から記事へ直接来る人の入口と、目次を整える改修で、この置き場所を変えました。記事ごとに、通常のHTML本文を<noscript>の外へ生成する。店内の本をめくる表示と、普通にスクロールして読む表示を、同じ原稿から維持する形です。
本の演出と、本文を届ける経路を分けます。記事のURLへ直接来た人には、最初のHTMLにその記事の本文を入れて渡す。演出は、その上に足します。
記事のURLへ、本文ごと届ける
演出を作っていると、サイトのトップから入って、本を開いて、記事を選ぶ順番で確認しがちです。
一方、記事へのリンクから来る人は、途中の受付を通っていません。最初の要求が、すでに記事そのもののURLです。
Streetのビルド処理は、記事ごとにHTMLを生成します。そのHTMLには、タイトル・段落・紹介本・ほかの記事へのリンクが入っています。記事を指定したURLでは本文を初期表示し、記事をまだ選んでいない本屋の入口では本文欄を隠す、という分担です。
| 入口 | 最初に渡すHTML |
|---|---|
| 本屋の入口 | 記事一覧と案内。未選択の本文欄は非表示 |
| 記事のURL | 指定した記事のタイトルと全文、紹介本、関連する入口 |
| JavaScriptが動いたあと | 同じ原稿データを使い、本の演出や表示切り替えを追加 |
URLを一つ増やすのと、そのURLに来た人を迎えられるのは、別の作業です。
たとえばhistory.pushState()は、表示中のURLと履歴を変えます。その呼び出しだけで、新しいURLのHTMLを読み込むことはありません。画面内の移動が動いていても、URLを別のタブで開いたときに同じ記事を渡せるかは、改めて確かめます。
入口が二つでも、原稿まで二つにしない
普通の文章と、本の演出。両方を別々に手書きすると、修正のたびに二つの原稿を探すことになります。
片方で誤字を直したら、もう片方は古いまま。二冊並んでいるように見えて、実態は改訂版と初版です。読者に間違い探しを任せるつもりはありません。
Streetでは、JSONにある段落の配列を出発点にしています。
- ビルド時は、その段落から普通のHTMLを作る。
- ブラウザー側は、同じデータから本の紙面やスクロール表示を組み立てる。
生成したHTMLの段落と、ブラウザーに渡すデータの段落を照らし合わせると、配信する版のずれに気づけます。たとえば、生成したHTMLだけを配信し、ブラウザー用データの更新を忘れた場合は、片方にだけ新しい文章が届きます。
二つの表示を持つなら、「両方にあるか」だけでなく、「両方が同じ原稿を見ているか」を確認する。店舗数を増やす前に、原稿の置き場所を一つにします。
小さく作るなら、まず二つのHTMLを出す
記事用の例では、二つの記事を一つのJSONから生成します。次のJSONをUTF-8でcolumns.jsonとして保存します。idは例で使う固定のoneとtwoです。
[
{"id":"one","title":"準備中の札を外す","paragraphs":["本屋に来たら、まず本文を読めるようにします。","演出担当は、そのあとゆっくり照明を合わせます。"]},
{"id":"two","title":"原稿の置き場所","paragraphs":["本の形でも、普通の文章でも、出発点の原稿は同じです。","本文に <script> と書いても、文字として表示します。"]}
]
次が生成処理です。原稿と同じフォルダーへbuild.pyとして保存します。段落はhtml.escape()を通し、本文に書いた<script>などを文字として出力します。
import html
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
columns = json.loads((ROOT / 'columns.json').read_text(encoding='utf-8'))
output = ROOT / 'dist'
output.mkdir(exist_ok=True)
for column in columns:
title = html.escape(column['title'])
paragraphs = ''.join(
'<p>' + html.escape(text) + '</p>'
for text in column['paragraphs']
)
links = ''.join(
'<li><a href="../' + row['id'] + '/index.html">'
+ html.escape(row['title']) + '</a></li>'
for row in columns
if row['id'] != column['id']
)
page = f'''<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
body {{ max-width: 44rem; margin: 2rem auto; padding: 0 1rem;
font: 17px/1.9 system-ui, sans-serif; background: #faf7ef; color: #283b32; }}
article {{ padding: 1.5rem; border: 1px solid #c4cdbb; }}
article.book {{ columns: 2 16rem; column-gap: 2rem; box-shadow: 0 8px 20px #283b3222; }}
article.book h1 {{ column-span: all; }}
button {{ font: inherit; margin-bottom: 1rem; }}
[hidden] {{ display: none !important; }}
</style>
<button id="view" type="button" hidden aria-pressed="false">本の見た目にする</button>
<article><h1>{title}</h1>{paragraphs}</article>
<nav aria-label="他の記事"><ul>{links}</ul></nav>
<script src="enhance.js" defer></script>
</html>'''
folder = output / column['id']
folder.mkdir(exist_ok=True)
(folder / 'index.html').write_text(page, encoding='utf-8')
(folder / 'enhance.js').write_text((ROOT / 'enhance.js').read_text(encoding='utf-8'), encoding='utf-8')
次節のenhance.jsまで同じフォルダーに保存してからビルドすると、次の構成になります。
| ファイル | 開いたときに得られるもの |
|---|---|
dist/one/index.html |
一つ目の記事の全文 |
dist/two/index.html |
二つ目の記事の全文 |
各フォルダーのenhance.js
|
本の見た目に切り替える操作 |
演出係が来たら、担当のボタンを渡す
同じフォルダーにenhance.jsを置きます。
const article = document.querySelector('article');
const button = document.querySelector('#view');
button.addEventListener('click', () => {
const book = article.classList.toggle('book');
button.setAttribute('aria-pressed', String(book));
button.textContent = book ? '普通の文章に戻す' : '本の見た目にする';
});
button.hidden = false;
記事用の例は、同じarticleにCSSの段組みを付け外しします。Streetの別レイヤーの紙面やページ分割は省き、本文を最初からHTMLへ入れる判断だけを小さくしています。
Pythonで先にHTMLを書き出す部分が静的生成、読める状態にJavaScriptで操作を足す部分がプログレッシブエンハンスメントです。生成に使う道具が変わっても、全文を含むHTMLを先に渡す分担は共通です。
ボタンには最初からhiddenを付け、イベントを登録できたあとに表示します。JavaScriptが読み込まれなければ、普通の本文と記事間のリンクが残ります。使えるようになった操作を、その段階で読者へ渡します。
<noscript>を置く方法もありますが、出番はスクリプトが無効なときなどです。JavaScriptを有効にしたままファイルの取得に失敗した場合まで、一律に代わりの画面を出す仕組みにはなりません。この例は本文を通常のHTMLとして置き、その本文を読むための初期化を不要にしています。
「動かす」確認に、「届いている」確認を足す
実行するコマンドは次のとおりです。
python3 build.py
生成されたdist/one/index.htmlを開くと、一つ目の記事を読めます。記事間のリンクをたどると、二つ目の記事へ移動します。JavaScriptが動けば、ボタンで段組み表示に切り替えられます。
開発者ツールのNetworkでenhance.jsをリクエストブロックし、再読み込みしてみます。本文と「他の記事」へのリンクが残り、段組みのボタンは隠れたままなら、演出係を待たずに読めています。ブロックを解除して再読み込みすると、ボタンが現れ、普通の文章と段組みを行き来できます。
次はJavaScript自体を無効にして、同じ記事URLを開き直します。こちらでも本文が読めるかを見ます。ファイルの取得失敗と、JavaScriptの無効化は、両方試しておきます。
最後に、二つ目の記事のURLを別のタブへ直接貼り付けます。最初の画面から二つ目のタイトルと段落が読め、「他の記事」には一つ目だけが並ぶことを確かめます。本文中の<script>も、画面では文字として読めます。
内装業者より、先に入れる店
この分担には手間もあります。原稿を直したらHTMLを再生成し、ブラウザーへ渡すデータも同じ版にそろえる。記事数が増えれば生成物も増えます。普通の表示と本の表示、それぞれの入口を確認する仕事も残ります。
その代わり、本文を読めるようにする作業と、本屋らしく見せる作業を切り離せます。演出を試すたびに、読者まで工事現場へ連れていく必要が減ります。
照明係がまだ配線を確認していても、読者は席について本を読んでいる。
「準備中」の札は、店の入口から照明係の机に移りました。