数の一致:{plural <count>: 形1|形2|形3}

日本語には数による名詞の変化がありません。だからこそ、この機能が要るのは出力先の言語が変化する場合です。ロシア語 — そしてすべてのスラブ語 — は、前に置かれた基数詞に名詞を一致させます:1 язык、2 языка、5 языков。選択は100と10による剰余で決まり、11〜14には例外があります。Spintaxは、これを第一級のプリミティブとして備えた最初のspintax系エンジンです。それ以前は、各編集部がテンプレートの中で毎回作り直しては、どこかで間違えていました — あるいは、数を含む言い回しを黙って避けていました。

構文

{plural <count>: form1|form2|form3}

リテラルの接頭辞 {plural (末尾に半角スペース1つ)が、類語の形 {a|b|c} と区別する曖昧さのない目印です。: が数のスロットと形のスロットを分けます。形は縦棒で区切ります。

ロケールの系統言語形の数
東スラブ ruukbe 3one|few|many
BCS(セルビア・クロアチア・ボスニア) srhrbs 3one|few|many
EN式(既定) enesptdeitfrnlsvnodafi、… 2one|many

セルビア語・クロアチア語・ボスニア語は、東スラブのバケットのルールを一字一句そのまま使います — 1、21、101(ただし11は除く)がone、2〜4、22〜24(ただし12〜14は除く)がfew、ゼロを含むそれ以外がmanyです。CLDRはBCSのこの3つ目のバケットを many ではなく other と呼びますが、位置としては同じスロットなので、ロシア語の形数で書いたテンプレートはそのまま動きます。

アラビア語・ウェールズ語・ヘブライ語・ラトビア語はバケット構造が異なり、意図的に実装していません。実需要が見えた言語から順に入れていきます。

supports %LangCount% {plural %LangCount%: language|languages}
ships with %IntegrationCount% {plural %IntegrationCount%: integration|integrations}
processed in {plural %PayoutHours%: hour|hours}

поддерживает %LangCount% {plural %LangCount%: язык|языка|языков}
получите %BonusCount% {plural %BonusCount%: бонус|бонуса|бонусов}
завершить за {plural 30: день|дня|дней}

数のスロットには %Var% 参照か整数リテラルを置けます。複数形のパスが走る時点で変数置換は済んでいるので、ヘルパーが目にするのは常に整数の文字列だけです — 編集者がどちらの形で書いたかに関係なく。

ロケールのルール(RUの3バケット)

ロシア語のルールは面倒なことで有名です。全体の表:

nバケットRUの例
1, 21, 31, 41, …, 101, 121one1 язык、21 язык
2–4, 22–24, 32–34, …few2 языка、23 языка
0, 5–20, 25–30, 35–40, …, 100, 111–114many0 языков、11 языков、25 языков

11〜14の例外(下1桁だけ見ればonefewに見えるもの)が、その場しのぎの回避策を壊します。エンジンのヘルパーは、すべての編集者・すべてのカウンター・すべてのテンプレートのために、この穴を一度きりで塞ぎます。アルゴリズム:

const abs = Math.abs(n);
const mod10 = abs % 10;
const mod100 = abs % 100;

if (mod10 === 1 && mod100 !== 11) return forms[0];                                  // one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1];     // few
return forms[2];                                                                    // many

負数はCLDRと同じく abs を通します。ゼロがmanyの形を取る(「0 языков」)のは、ロシア語ではそれが文法的に正しいからであって、ゼロが特別扱いだからではありません。

なぜコロン区切りなのか({plural %N%|形} ではなく)

初期の草案は {plural %LangCount%|язык|языка|языков} と、縦棒だけで書いていました。構造上の問題が2つあって、この形は没になりました:

1.ヘルパー変数の危険。プリセットでよくあるマクロ:

#set %LangPlural% = {plural %LangCount%: язык|языка|языков}

変数置換のあとにこの構造が {12|язык|языка|языков} になっていたら、4択の類語と見分けがつきません — そして次のパイプライン段階が、何食わぬ顔でどれかをランダムに選びます。コロンの形なら展開を通しても識別用の接頭辞が残るので、複数形のパスを変数置換のあとに安全に走らせられます。

2.整数リテラル。{30|день|дня|дней} は類語の {a|b|c} と形が衝突し、パーサーには区別できません。コロンの形なら {plural 30: день|дня|дней} は構造として別物です。

数値の境界ケース

数のスロットは厳密に解析されます。編集者の意図と違う意味に黙ってなってしまう値なら、その構造は空文字列に解決されます。

数のスロット結果理由
12選ばれた形ただの整数
-33 として選ばれた形abs()、CLDRに合わせる
0選ばれた形(RU: many、EN: many)ゼロは文法的
12 12 として選ばれた形空白は除去される
(空)構造全体 → 空数がない
%MissingVar%(置換されなかった)構造全体 → 空展開後も数ではない
1,200構造全体 → 空カンマは数字ではない。parseInt なら嘘をついて 1 を返す
12abc / 08h構造全体 → 空末尾の非数字は却下
1.5構造全体 → 空v1では整数のみ

数が無いときに(構造だけでなく)文ごと消したいなら、文全体を条件で囲みます:

{?HasLanguages?supports %LangCount% {plural %LangCount%: language|languages}|}

形のスロット — 入れ子のspintaxかっこは不可

形はプレーンテキストでなければなりません。入れ子のspintaxかっこ { } [ ]含められません{plural 1: {a|b}|c} のような形は却下され、{plural 1: [<and>day|days]} も同様です。バリデーターはこれをエラーとして報告し、実行時は寛容に、例外を投げる代わりに全角かっこへ格下げします(下記参照)。

形の中にどうしても条件付き・ランダムな内容が必要なら、先に変数へ引き上げてください — ただし #set ではなく #def です:

/# wrong: nested synonym in form #/
{plural 2: {integration|connector}|integrations}

/# also wrong: #set is a macro — the brackets come straight back #/
#set %Noun% = {integration|connector}
{plural 2: %Noun%|%Noun%s}
→  {plural 2: {integration|connector}|{integration|connector}s}

/# right: #def resolves once, so the form slot receives plain text #/
#def %Count% = 2
#def %Brand% = {Acme|Acme Cloud}
{plural %Count%: %Brand% integration|%Brand% integrations}
→  Acme Cloud integrations

#def の価値はまさにこの違いにあります。#set は参照のたびに値をそのまま差し込むので、類語を #set に引き上げると、きれいに保ちたかったスロットへかっこがそのまま戻ってきます。#def はレンダリングごとに一度だけ値を確定し、形のスロットには解決済みのテキストを渡します。

引き上げているものに注目してください:どの形でも同じ、語形変化しない断片です。このパターンが支えるのはその形だけです。名詞を引き上げて、変数に接尾辞を連結して各形を組み立てようとしないこと — %Noun%а はある類語ではうまくいっても、次の類語では意味不明になります。語そのものが変化するなら、形を省略せずに書き出してください。形のスロットはそのためにあります。

HTMLタグ(<em><a href="…">)や未解決の %Var% は、形のテキストの中で無害に残ります — 禁じられているのは構造としてのspintaxかっこだけです。

寛容な実行時 — 壊れた構造でページは落ちない

形のスロットにかっこが紛れ込んだり、形の数がロケールの形数と合わなかったりすると、エンジンはブロック単位でエラーを捕まえ、その構造を全角かっこU+FF5B / U+FF5D)でそのまま出力します:

supports 5 {plural 5: язык|языка}

全角かっこはASCIIの {} とほとんど同じ見た目ですが別のコードポイントです — 後続のパイプライン段階を、列挙リゾルバーに誤読されることなく通り抜けます。ページは表示され、バグはHTML上で目に見え、運用は500を出さずに直せます。

一方、バリデーター(とplayground)は厳格モードで動きます:どちらのエラー分類も position フィールドと構造の原文を添えて例外を投げます。テンプレートが本番に出る前に、書き手が間違いに気づけます。

ロケールはどこから来るのか

レンダリング呼び出しごとに1つのロケール。v1では構造ごとの上書きはありません。

  • WordPressプラグイン:テンプレートごとの投稿メタ _spintax_locale が優先され、無ければWordPressサイトのロケール(get_locale())にフォールバックします。
  • @spintax/core(単体):render(tpl, { locale })locale フィールド。どこから取るかはホスト側の判断です — リクエストヘッダー、ユーザー設定、サイト設定。省略すれば2形の既定になります。
  • Playground:ページの言語に従います — /play/ENは英語、/ru/play/はロシア語。ページ内にロケール切り替えはありません。ナビゲーションの言語切り替えでページごと移動してください。

ロケール文字列は基底タグに正規化されます — ru-RUruuk_UAukpt-BRpt。形数の表は基底タグで引きます。文字体系や地域のサブタグは複数形の文法を持たないので、sr-Latnsr-Cyrlsr_RSsr-Latn-RS はすべて sr に正規化され、同じ3形になります。3文字のタグは対応付けされませんsrpsrp のままで、2形の既定に落ちます。

パイプライン上の位置

1. strip comments
2. extract #set directives
3. apply conditionals          (pass 1)
4. expand %var% references
5. apply conditionals          (pass 2)
6. apply plurals               ← this stage
7. resolve enumerations
8. resolve permutations
9. post-process

複数形のパスは、変数展開のあと(数のスロットの %LangCount% がすでに整数の文字列になっているように)、そして列挙の解決の(類語リゾルバーが壊れた構造を誤読する機会を持たないように)に走ります。

実例:製品比較

SaaS比較カタログの3行です。同じテンプレートがどの行も描画しますが、数が名詞の形を決め — その結果として — 本文の具体性まで左右します。

製品言語数連携数プラン数
Acme16183
Beta115
Gamma12172

複数形のプリミティブが無ければ、どの製品も同じ曖昧な一文になります:「supports many integrations, including Slack, GitHub, Linear」。カタログ上の違いは見えません。プリミティブがあれば、テンプレートはこう言えます:

supports %IntegrationCount% {plural %IntegrationCount%: integration|integrations},
including %TopIntegrations%

行ごとの出力:

  • Acme:supports 18 integrations, including Slack, GitHub, Linear
  • Beta:supports 1 integration, including Slack
  • Gamma:supports 17 integrations, including Slack, GitHub, Linear

事実の違いが本文に出ました。SEOは本物の差別化から利益を得て、読者は「たくさん」ではなく具体的な数を得ます。

アンチパターン

1.閉じた集合のインライン対応

「たまたま動く」回避策:

{50|100|150|200} баллов

選ばれる数がどれも偶然manyの形を要求するので、名詞が食い違いません。数が本物の変数から来た瞬間に壊れます — 21〜24や31〜34の値はどれも間違った形と組み合わさります。

2.バケット判定フラグによる条件分岐

「エンジニアを間に挟む」回避策:

%LangCount% {?HasOneLang?language|{?HasFewLangs?languages|languages}}

数えられる対象ごとに真偽フラグを3つ変数アセンブラーに足し、テンプレートごとに入れ子の条件を書く。編集者は新しい %count% %noun% の言い回しを書くたびに、まずエンジニアにフラグ3点セットの追加とビルドの公開を頼まなければなりません。このプリミティブは、まさにこの流れをなくすために作られました。

3.数の代わりにリストで包む

「黙って避ける」回避策:

supports many integrations, such as %TopIntegrations%

ツールが数を表現できないので、編集者が数を迂回します。結果として、どの項目も同じ読み味になり、SEO上の差別化も編集上の説得力もありません。数を出してください。

業界での位置づけ

数の一致は、どのi18nスタックでも第一級のプリミティブです:ICU MessageFormat({count, plural, one {…} few {…} other {…}})、gettextの ngettext、FormatJSなど。まともなコンテンツ基盤なら備えていて当然の、普遍的な文法プリミティブの一群に属します。

私たちが変えたところ:これをspintax固有のプリミティブにしました。ICUは別のテンプレート構文を要求するので、プラットフォーム上の既存の {a|b|c} をすべて移行する話になります。{plural N: …} は既存の表面にそのまま収まります — 同じ波かっこ、同じ縦棒、同じ「段階を追って組み立てる」思考モデルです。

クイックチェックリスト

  • 数+名詞を出力するところでは必ず {plural %N%: 形1|形2|形3} を使う。「2形しか要らない」英語だけのサイトでも同じ。
  • ロケールの形数を守る。RU/UK/BEとSR/HR/BSは3形、EN式は2形。不一致はバリデーターが捕まえる。
  • 形はプレーンテキスト。入れ子の {}[] は不可 — 先に #def で引き上げる。#set では駄目:マクロなのでかっこがそのまま戻る。
  • 数が空、または数値でない → 構造は空になる。文ごと消したいなら {?HasFoo?…|} で囲む。
  • 負数は abs() を通る。ゼロはmanyの形。小数は厳密判定で落ちる — 数は整数に保つ。
  • ロケールはテンプレート単位(投稿メタ)かレンダリング呼び出し単位で1回設定する。構造ごとの上書きはまだ無い。
  • 寛容な実行時は、壊れた構造を全角かっこでそのまま出力する — 静かな破損ではなく、目に見えるバグとして。バリデーターは厳格に動く。

その場で試す

playgroundENには {plural %Count%: language|languages} の例が入っています。%Count% を0、1、2、5、11、21、22と動かして2形(EN)のバケット規則を確かめ、ナビゲーションの言語切り替えでロシア語のplaygroundへ移れば、同じテンプレートが3形の規則とロシア語の名詞形で動くのが見られます。

デスクトップのエディタがよいですか。Spintax Studio — Microsoft Storeで配布しているWindowsネイティブのエディタ — はオフラインで、入力しながら検証します:診断パネルはこのページと同じコード(plural.arityplural.count-macro)を話し、それぞれに内蔵のヘルプ記事があります。ロケール選択でエンジンが検証する形数を切り替えられるので、enru を行き来して1つのテンプレートで両方の規則を試せます。


シリーズを続ける