0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

全文はnoscriptの中にあった――個人開発の本屋で、演出より先に本文を届ける

0
Posted at

個人開発の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を再生成し、ブラウザーへ渡すデータも同じ版にそろえる。記事数が増えれば生成物も増えます。普通の表示と本の表示、それぞれの入口を確認する仕事も残ります。

その代わり、本文を読めるようにする作業と、本屋らしく見せる作業を切り離せます。演出を試すたびに、読者まで工事現場へ連れていく必要が減ります。

照明係がまだ配線を確認していても、読者は席について本を読んでいる。

「準備中」の札は、店の入口から照明係の机に移りました。


参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?