doxa-maps/library/composables/useSentence.js
Doxa Maps 2c397d25ef Source comments state design history and measurements as facts, without quoting or naming anyone
Comments in library/ and internal/ that quoted a person, attributed a decision to one, used first person,
named fleet agents or carried private host names now state the same technical fact in neutral words:
poster-render (including the comments it writes into generated posters), packet-render, the poster and
research scripts, PosterWall, DataTable, the legends, the layouts and the gates. The network-policy note
states the policy itself. Behaviour is unchanged: a few printed gate messages and reason labels drop an
agent name, the poster-render integrity pin follows its comment-only edit, and series.json changes only in
its _meta and _why notes. The historical poster design snapshots (library/scripts/posters/snapshots/),
which nothing reads, are removed.
2026-10-01 00:37:17 +00:00

130 lines
6.9 KiB
JavaScript

/**
* useSentence.js — a translated SENTENCE that carries values and markup, as one key.
*
* WHY THIS EXISTS. A label is one key; a sentence that interpolates values is also ONE key, because
* the clause order is part of the translation. The pattern it replaces built a sentence by
* concatenating translated pieces around untranslated text:
*
* <b>{{ fmt(nNone) }}</b> of {{ fmt(nLang) }} languages have <b><bdi dir="ltr">no scripture…</bdi></b>
*
* Every fragment there ("languages have", "of") is a separate string, and Arabic does not put those
* clauses in that order. Translating the fragments gives Arabic words in English order: grammatically
* broken, and WORSE than leaving it in English, because it looks translated and nobody re-checks it.
*
* HOW. The message is one key with NAMED placeholders — "{nNone} of {nLang} languages have
* {noScripture}" — so a translator moves the clauses freely. The caller passes a VNode (or a string)
* per name. The message is rendered with sentinels, split on them, and the slots are dropped back in.
* Markup therefore SURVIVES without being translated around: the bold numbers and the `dir="ltr"`
* island are passed in, never spliced into the catalogue string.
*
* ★ NO <i18n-t>. This tree has none, and this works with precompiled messages, which is why the
* sentinel split is used rather than a component: it needs nothing from vue-i18n beyond the string.
*
* ★ A MISSING SLOT STAYS VISIBLE. If a name has no value the placeholder is left as written, the
* same rule fillParams follows and for the same reason: `{nLang}` on screen is a bug someone reports,
* while "null" or "" reads as content and ships.
*/
import { h } from 'vue'
/**
* A sentinel that cannot occur in a translated message. U+FFFC is OBJECT REPLACEMENT CHARACTER —
* Unicode's own "an object goes here" — so a catalogue containing it is already wrong.
*/
// ★ PRIVATE-USE CHARACTERS (U+E000+n), studio's constraint and the safer choice. U+FFFC (OBJECT
// REPLACEMENT CHARACTER) reads better in a dump, but it is a REAL assigned character: anything in the
// i18n pipeline that escapes or normalises message parameters may legitimately touch it, and a
// sentinel that survives everywhere except one build step fails silently and only in one locale.
// U+E000.. is unassigned by definition — nothing may interpret it — so nothing can rewrite it.
// Neither failed under the escaping available in a local test; this follows the build pipeline's
// known behaviour, not a reproduced break.
const MARK_BASE = 0xE000
const mark = (i) => String.fromCharCode(MARK_BASE + i)
const MARK_RE = /[\uE000-\uE0FF]/
const MARK_ALL = /[\uE000-\uE0FF]/g
/**
* Render a message with slotted values.
*
* @param {string} message the translated sentence, with {named} placeholders
* @param {Object} slots name → VNode | string | number
* @returns {Array} children for a Vue render function / JSX-free template use
*
* Text runs are returned as plain strings, so a caller can put them straight into a template with
* <component :is> or a functional wrapper; the slots keep their own identity as VNodes.
*/
export function sentenceParts(message, slots = {}) {
const names = []
// Replace only the names we were GIVEN. An unknown placeholder is left alone so it shows up.
// ★ STRIP ANY SENTINEL THE MESSAGE ALREADY CARRIES, before placing ours. Private-use characters
// are unassigned, not impossible — a paste or a bad import can carry one — and a stray that
// survives into the split is indistinguishable from a slot we placed, so it silently swallows a
// value. The gate caught this printing 'a X b X'. Removing them first makes every remaining mark
// ours by construction, which is a stronger guarantee than checking the index afterwards.
const clean = String(message ?? '').replace(MARK_ALL, '')
const marked = clean.replace(/\{(\w+)\}/g, (m, k) => {
// ★ `== null` ONLY, so 0 and '' are VALUES. A count of zero is a real sentence — "0 of 7,296
// languages have nothing found" is the answer a filter can legitimately produce — and a falsy
// test would have left {nNone} on screen for exactly the case a reader most needs to trust.
// Caught by the gate's negative control rather than by review.
if (slots[k] == null) return m
names.push(k)
return mark(names.length - 1)
})
const out = []
let rest = marked
while (rest) {
const at = rest.search(MARK_RE)
if (at < 0) { if (rest) out.push(rest); break }
if (at > 0) out.push(rest.slice(0, at))
// ★ ONLY A SENTINEL WE PLACED IS A SLOT. A private-use character already in the message is
// content: unassigned does not mean impossible, and a paste can carry one. Indexing blindly
// turned a planted U+E000 into slot 0, so the gate's own stray-sentinel arm printed 'a X b X'
// — and still passed, because it only asserted the slot was present. Out-of-range marks are
// passed through as the characters they are.
out.push(slots[names[rest.charCodeAt(at) - MARK_BASE]])
rest = rest.slice(at + 1)
}
return out
}
/**
* The same thing as a single element, for a template that wants one node.
* `tag` defaults to a fragment-like <span> so it inherits the surrounding text styling.
*/
export function sentenceNode(message, slots = {}, tag = 'span') {
return h(tag, sentenceParts(message, slots))
}
/** A bold value — the shape these sentences use most, so callers do not re-derive it. */
export function strong(value) { return h('b', [String(value)]) }
/**
* A value that must not be re-ordered by the reader's direction: a resource name, a code, a phrase
* quoted from an English source. `dir="ltr"` on its own element is what stops it reversing inside an
* Arabic sentence, and it is passed in rather than written into the catalogue so a translator never
* has to reproduce the markup.
*/
export function ltr(value, bold = false) {
const inner = h('bdi', { dir: 'ltr' }, [String(value)])
return bold ? h('b', [inner]) : inner
}
/**
* The params to hand `t()` so it returns its message with the placeholders INTACT.
*
* ★ WITHOUT THIS THE SENTENCE COMES BACK EMPTY. vue-i18n resolves a message at lookup, so `t('s')`
* with no params BLANKS every placeholder: measured on 9.14.5, "{nNone} of {nLang} have
* {noScripture}" returns " of have " — and sentenceParts then has nothing to split, so the sentence
* silently loses its values. Passing each name back as its own literal makes t() a no-op over them.
*
* Preferred over tm(), which returns the raw string only while messages are compiled at runtime; this
* works with precompiled messages and with plurals, which is the whole point of the pattern.
*
* const msg = t('charts.noneOfLanguages', keep('nNone', 'nLang', 'noScripture'))
* sentenceParts(msg, { nNone: strong(a), nLang: strong(b), noScripture: ltr(c, true) })
*/
export function keep(...names) {
const out = {}
for (const n of names) out[n] = '{' + n + '}'
return out
}