zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Publication Design — 公開ページのレンダリングとスナップショット

zudo-text の内蔵公開は、note から独立した公開ページのスナップショットを作る、範囲を絞ったサービスです。編集用プレビューをそのまま配信せず、公開専用の portable renderer が Markdown を検査して静的 HTML に変換します。外部サイトのビルドや frontmatter 規約は引き受けません。哲学と Publish API も参照してください。

次期の profile・時系列一覧・tags・URL・権限の提案は Publish Next ADR に分けています。そちらは未実装の契約であり、このページは現行の公開 renderer と snapshot の説明です。

所有境界と API

@takazudo/publication-renderer (packages/publication-renderer/) は React、Tauri、DOM、エディタの scheduler に依存しません。composer と publish Worker が同じ契約を使います。

  • initPublicationRenderer() は parse-only Wasm を冪等に初期化します。renderPublicationBody(markdown, options) も必要時に遅延初期化し、PublicationBodyResult (html, headings, titleFromBody, diagnostics) を返します。

  • renderPublicationDocument({ bodyHtml, headings, presentation, page }) は文書全体を組み立て、documentHtml, styleDigest, documentDigest, rendererVersion を返します。bodyHtml には renderPublicationBody が検証したものだけを渡します。

  • fillLiveProfileSlots(documentHtml, profile, { assets, isProtected }) は配信時と composer の preview 時に固定スロットを埋めます。defaultPresentation(), applyStarter(), validatePresentation(), publicationFingerprint() は公開デザインと同一性の契約です。

  • renderMarkdown と computeContentHash は従来の同期呼び出し向け互換 export で、公開 body pipeline の fallback ではありません。

body pipeline は正確な元 Markdown に位置情報を結び、parse-only Wasm → trustMdastRoot → transformMarkdownDocument → createSafeHast → 公開用の静的 mapping → 最終 tag/property/URL・resource 検査 → HTML serialization の順に進みます。失敗時は空の安全な body と blocking diagnostic を返します。Worker には未検証 HTML や CSS を入力させず、presentation を厳格に decode します。parser の選択、workerd 用 pinned patch、意図した preview との差分は S8 の決定 #6885 と 実装 #6888 に記録されています。

ページごとの presentation

PublicationPresentation (formatVersion: 1) は starterId, layout, readingTheme, options, language, sharing を持ちます。layout は article / guide / journal / plain。starterId はギャラリーでの出発点を記録し、適用すると layout、組み込み reading theme、options をコピーします。後から個別に変えた値が公開結果です。options は reading width、著者・公開日・更新日の表示、目次の自動表示を選びます。sharing の title/description が null ならページの title/description を継承します。theme は組み込み slug のみで、custom CSS は公開できません。

composer は新規公開のほか、既存ページの Edit design に使います。既存ページは最後に公開した Markdown・metadata・presentation を baseline として開き、Use current note content は現在の note を明示的に取り込みます。Draft / Published は編集中の結果と保存済み文書を比較します。Desktop / Phone は同じ内容の表示幅を切り替えます。composer 内で note や target が変わった場合は、古い確認を再利用しません。

スナップショットと配信

作成・追加・置換・PUT は Worker 側で本文と文書 HTML を作り、元の content_markdown、content_html、document_html、presentation_json、renderer_version、publication_fingerprint を保存します。公開ページは保存済み文書を返すので、後から note や著者プロフィールを編集しても本文・デザインは自動更新されません。古い document_html を持たないレコードには従来の HTML から組み立てる互換経路があります。owner-only GET /api/v1/pages/:id/snapshot は markdown, presentation, title, description, slug, revision, documentHtml, assets を返し、通常の一覧 metadata に文書 HTML を載せません。

保存文書には <!--ztp:canonical-->, <!--ztp:og-url-->, <!--ztp:author-->, <!--ztp:author-link--> という固定スロットを置きます。共有画像を選んだ文書には <!--ztp:og-image--> も置きます。配信時に fillLiveProfileSlots が現在の username、display name、公開 base URL から canonical/OG URL と著者表示を組み立て、置換値を HTML escape します。プロフィール変更で公開 HTML 全体を再レンダリングしません。公開 HTML の CSP は従来どおりで、script は許可しません。パスワード challenge の form だけ form-action 'self' を使います。

fingerprint、revision、retry

publicationFingerprint() は元 Markdown、公開 title/description/slug と、解決済み presentation (layout/theme/options/language/sharing) の順序を固定して SHA-256 を計算します。starterId、生成時刻、live profile 値、短命な upload ID は含めません。cover と共有画像は内容 digest・代替テキスト・装飾状態・焦点位置など公開結果を変える値を含めます。RENDERER_VERSION は描画出力が変わる際に更新する別の値です。同じ fingerprint で公開中のページへの更新は no-op として revision を進めません。変更時は publishRevision を 1 増やします。

作成 POST /api/v1/pages は Idempotency-Key header、明示的な replace/additional は body の idempotencyKey を使います。Worker は操作と挙動に関わる入力全体を fingerprint して D1 ledger に結果を保持します。同じ key・同じ request の再送は安全な結果を replay し、異なる request に key を再利用すると publish_idempotency_conflict です。replace と PUT は expectedRevision で compare-and-swap し、古い選択を publish_decision_stale で拒否します。PUT はさらに対象 page ID を明示し、archived page を更新できません。元 note の一致や slug だけで置換先を推測せず、publish_decision_required でユーザーの選択を求めます。

Diagnostics と現時点の限界

PublicationRenderDiagnostic は severity (blocking / notice)、code、人が読める message、判明している場合の元 Markdown の sourceLine を持ちます。blocking が 1 つでもあれば Worker は HTTP 422 publication_blocked と diagnostics を返し、保存しません。

Code扱い
unresolved_asset, attachment_link../assets/<path> の画像・添付で解決済み asset が無い、画像形式が非対応、または URL/path が無効なら blocking。明示的な HTTPS 画像はそのまま使える。
unsupported_directive, unsupported_footnote, unsupported_block未対応・不正な directive、未定義 footnote、SHEET table や parse/boundary/resource 失敗などは blocking。
invalid_slug, incompatible_theme不正な URL slug や公開不可の preview theme を composer で示す。
private_note_link私有 .md note へのリンクは notice を付け、本文では読める文字列にする。
raw_html, diagram_fenceraw HTML は実行せず文字として表示。Mermaid は図ではなくコードとして表示し、notice を付ける。
duplicate_title本文冒頭の H1 がページ題名と異なる場合の notice。題名と一致する H1 は重複表示しない。

通常の Markdown/GFM、登録済み admonition・steps・cards・figures・badge、定義済み footnote は静的 HTML として扱えます。ブラウザ側での directive 操作、Mermaid の描画、コード highlight Wasm、SHEET の計算は現契約に含まれません。詳細な実装 inventory は packages/publication-renderer/README.md を参照してください。

公開 asset の契約

Markdown の画像・添付リンクと directive の画像・href は、正規化できる ../assets/<path> を参照します。画像は PNG、JPEG、GIF、WebP、AVIF のみです。/assets、asset:、private note、任意のローカルパスは公開 asset として扱いません。composer は参照を集めて workspace から読み、画像の実データと拡張子を検査し、本文 asset を upload します。cover と共有画像は workspace の画像 picker で選びます。cover は代替テキストまたは装飾指定と焦点位置を持ち、共有画像には代替テキストが必要です。

POST /api/v1/publication-assets は JWT と購読資格を要し、raw bytes、X-Asset-Role: image | attachment、X-Content-SHA256 を受け取ります。1 ファイルは最大 25 MiB、1 ユーザーの有効な staging 合計は 500 MiB、24 時間の upload 試行は 300 回です。upload は 24 時間で期限切れになり、同じユーザー・digest・role の再 upload は期限を延長します。公開操作の assets[] は本文の各 (ref, kind) と選択した cover / share を漏れなく宣言し、SHA-256 と uploadId または同じページで既に公開された published identity を渡します。50 件・重複 digest 合計 100 MiB がページ上限です。選択漏れ、過剰宣言、所有権・digest・role の不一致は拒否します。期限切れ upload は 409 asset_upload_expired と再 upload が必要な refs を返します。

Worker は公開時に pages/<pageId>/<sha256> にコピーし、D1 の page asset 行と結びます。本文画像・cover・共有画像は /p/:pageId/i/:sha256、添付は /p/:pageId/d/:sha256(download)を使います。公開 route は現在の page、profile、公開状態、asset role、パスワード capability を照合し、失敗時は private/no-store の 404 にします。所有者の preview は JWT 付き /api/v1/pages/:id/assets/:role/:sha256 を使い、private/no-store です。非保護の asset は Cache-Control: public, max-age=60, s-maxage=600 のため、公開停止・slug 変更・asset 差し替え直後に共有 cache が最長 10 分ほど古い bytes を返す可能性があります。保護ページの asset は private, no-store です。古い presentation 行の cover と共有画像の省略は null に正規化し、画像の無い既存ページはそのまま表示できます。

2026-10 amendments

採択した実装契約(2026-10-06、#7328 / #7329)。以下は承認済み UI に合わせた追記です。この追記と以前の提案・現行説明が矛盾する場合は追記を優先します。追記自体は runtime の実装・release・deploy の証拠ではありません。上書きの全体表と型の正本は Publish Next ADR、描画境界は Publish OGP ADR を参照してください。

version 2 の snapshot と picture 所有権

PublicationPresentation を formatVersion: 2 とし、options.cover / focalPoint / sharing.image を除去します。thumbnail は post の {kind: "pattern", picture: PictureSpec} | {kind: "asset", sha256, width, height, pictureId} | null。写真は client で 3:2 の 1200×800 に一度だけ crop し、profile grid と article hero は同じ bytes を使います。client-made 1200×630 sharing derivative も持ち、roles thumb / ogp と inspectImage の実比率 1.5 ± 0.01 / 1.905 ± 0.01 を server が検証します。public DTO に thumbnailUrl を加えます。

PictureSpec は {pictureId, templateId, templateRevision, palette, customColors?, seedCounter}、pictureId は client UUID、counter は integer 0–999。seed は pic:<pictureId>:<seedCounter> なので preview と publish の間に post ID が発行されても同じ picture を使えます。post-level counter は設けません。pattern registry は thumb-3x2 / ogp、Palette と Pro Custom colors(最大 6 #rrggbb + background)、titled OGP の Title / Author / Text treatment を持ちます。custom colors は save 時の immutable copy です。

post の sharingCard は {mode: "card" | "picture" | "picture-title", treatment: "plain" | "outline" | "plate" | "band", followThumbnail, picture?, ogpAsset?}。unlinked non-card は picture 必須、写真 + picture は 1200×630 derivative の ogpAsset 必須、写真 + title は延期。legibility verdict は client derived value。mode / treatment / 連動 / resolved picture / asset digest は公開 snapshot の同一性と OGP identity に反映し、既存の publication no-op と stale revision の意味を維持します。pgen の renderHash と本文 publication fingerprint は別の identity です。

保存済み旧文書と atomic commit

旧 version 1 の stored document_html は従来どおり配信します。owner update は 409 snapshot_required とし、新 composer から version 2 の明示 snapshot を republish するまで自動 re-render / backfill しません。note 保存や profile default 更新で記事本文を取り込みません。停止・保護操作はこの更新 guard で妨げません。

create/replace の visibility(public / unlisted)と任意 initial password は最初の snapshot と atomic commit します。admission open の render 失敗は初回なら公開なし、update なら旧 snapshot と association を保持。admission closed は ogp.status: "none" と generic service card で publish 成功。automatic pictures の提供済み判定は S20 release-consumer proof の後で、generic fallback を proof と扱いません。

profile settings と配信境界

公開設定は PublicProfile の themeSelection / branding / customPalette / sharingCardDefault / thumbnailDefault に一組だけ保存し、Save as defaults と呼びます。expectedProfileRevision を検査し、409 profile_revision_stale を返します。通常 save は is_active を変えず、reactivate は明示 action、profile 未作成は Create your public profile step。Free は 4 curated themes、3 templates + 2 palettes、card / picture、必須 attribution。Pro は全 themes、overrides、treatments、custom colors、unlinked picture、attribution / own text / CTA。GET /api/v1/capabilities と 403 capability_required + field が server policy を表します。

/api/v1/* / /u/* を維持し public JSON は pages でなく ADR の items timeline に切替えます。public HTML / JSON / /p/ / /og / thumbnail に両 Cache-Control: no-store / CDN-Cache-Control: no-store、unlisted / protected に X-Robots-Tag: noindex。認可を内部 cache read より先に行います。後付け no-store が shared cache 済み応答を撤回できないため、従前の最大 600 秒を許容せず、この epic で render の内部再利用だけに絞ります。CSP、safe renderer、blocking diagnostics、owner scope、任意 HTML/CSS/script の拒否は維持します。

この epic からの延期

次の項目を後続 epic へ移します。このリストは実装済み・互換経路の追加を意味しません。

  • /api/v2 route set。

  • username/slug alias tables と、それらを使う 308 aliases。

  • POST /posts/:id/rename。

  • POST /posts/archives/purge。

  • idempotency ledger v2 fingerprint rules。

  • 写真 thumbnail 上の picture-title(photo + title)。pgen が image inputs を受け付けるまで延期。

  • named presets。v1 は profile 上の一組の既定値だけを持ち、操作名は Save as defaults。