doxa-maps/internal/check-embedded-rtl.mjs
doxa-maps-88655c70 0f6d504915
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
An RTL locale turns the plugin, not the partner's page
Arabic text was rendering inside a left-to-right layout: readable glyphs
in the wrong place, which reads as a styling quirk rather than the bug
it is.

TWO DEFECTS IN ONE BLOCK (library/i18n/index.js, createAppI18n):

  TARGET — it wrote document.documentElement.dir. An EMBEDDED widget
  doing that flips the PARTNER'S WHOLE PAGE: their headings, their nav,
  their footer. The existing comment already worried about the host
  owning that decision, but guarded only against CLEARING dir, never
  against setting it.

  TIMING — it ran at createAppI18n() construction, reading
  detectLocale(), which sees only the URL and <html lang>. A locale
  arriving later through profile-config — the channel a HOST uses —
  never reached it. Measured across the previous landing: htmlDir null
  on every host-channel run, including leaves whose chrome was fully
  Arabic.

Direction now binds on ProfileLoader's own root, driven by
effectiveLocale — the locale actually in force AFTER profile-config, so
it re-evaluates when a host changes the language. This element cannot
tell "I am the page" from "I am a widget", and guessing wrong is exactly
the failure above, so it no longer touches the document at all.

MEASURED on a partner page at a FOREIGN ORIGIN with
profile-config.locale='ar' (same gate, before and after):

  before   upg-100-mfe   plugin root ltr, arabic 1765   FAIL
           poster-wall   plugin root ltr, arabic 1738   FAIL
  after    upg-100-mfe   plugin root RTL, arabic 1765   pass
           poster-wall   plugin root RTL, arabic 1738   pass
  partner <html>/<body>/<h1> stayed ltr throughout.

Standalone leaves are unchanged and still get <html dir="rtl"> from
?lang=ar — verified, both profiles — because a leaf page DOES own its
document. createAppI18n's document write therefore stays for now;
replacing it needs a page-ownership signal that touches every generated
leaf, which is its own change rather than a rider on this one.

⚠ THE GATE REFUSES TO JUDGE WHAT IT CANNOT SEE, and that guard earned
its place twice before this was measurable. Its first two runs reported
"the element never mounted" — a HARNESS gap, not the product: a partner
embed uses crossorigin="anonymous" (SRI requires it), so the tree must
send Access-Control-Allow-Origin, which `bun run serve` does not. The
gate now proxies the tree through its own CORS-enabled port. Without
that guard those runs would have read as fails-first evidence for a
defect nothing had actually exercised. It also fails if the locale
produced zero Arabic chrome, because direction cannot be assessed on a
page whose language never applied.
2026-09-25 15:14:20 +00:00

152 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
/**
* check-embedded-rtl — an RTL locale must turn the PLUGIN right-to-left, and must NEVER
* turn the PARTNER'S PAGE right-to-left.
*
* TWO DEFECTS, one block (library/i18n/index.js, createAppI18n):
* 1. TIMING. It reflects RTL at CONSTRUCTION time from detectLocale(), which reads only the URL
* and <html lang>. A locale arriving later through profile-config — the channel a HOST uses —
* never reaches it, so Arabic text renders inside a left-to-right layout. Measured: htmlDir
* null on every host-channel run, including leaves whose chrome was fully Arabic.
* 2. TARGET. It writes document.documentElement.dir. An EMBEDDED widget doing that flips the
* partner's WHOLE PAGE. The existing comment already worries about the host ("a WordPress RTL
* theme owns that decision") but only guards against CLEARING it, not against setting it.
*
* So direction belongs on the plugin's OWN element, driven by the locale ProfileLoader actually
* applies; <html dir> stays only where the plugin owns the page (a standalone leaf).
*
* ⚠ THE HOST PAGE IS ON A DIFFERENT ORIGIN, deliberately: same-origin would let a bug pass that
* only bites a real partner. The tree is served on one port, the partner page on another.
*
* Usage: node internal/check-embedded-rtl.mjs [baseUrl]
*/
import { chromium } from 'playwright'
import { createServer } from 'node:http'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { servesThisTree } from './_serves-this-tree.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const BASE = process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:4173'
const HOST_PORT = Number(process.env.RTL_HOST_PORT || 47393)
let reachable = false
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
if (!reachable) { console.log(`EMBEDDED-RTL: SKIP — nothing served at ${BASE}.`); process.exit(0) }
const treeCheck = await servesThisTree(BASE, ROOT)
if (!treeCheck.ok) { console.error(`EMBEDDED-RTL: NOT MEASURED — ${treeCheck.why}`); process.exit(2) }
// A PARTNER PAGE on a foreign origin: its own <html dir> is LTR and must stay that way.
const CASES = [
{ id: 'my-upg-100-list', tag: 'my-upg-100-list-map', profile: 'upg-100-mfe' },
{ id: 'my-upg-100-list', tag: 'my-upg-100-list-map', profile: 'poster-wall' },
]
const TREE_PORT = Number(process.env.RTL_TREE_PORT || 47395)
const TREE = `http://127.0.0.1:${TREE_PORT}`
const hostHtml = (c) => `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>partner</title>
<style>html,body{margin:0}#w{height:900px}</style></head>
<body><h1 id="partner-heading">Partner page</h1><div id="w"></div>
<!-- A REAL PARTNER EMBED SETS THIS. The generated leaf pages bake window.MAP_APP_API_URL, and
without it every data fetch resolves same-origin against the partner and fails, so the wall
renders zero cards and the locale has nothing to translate. The first run of this gate
omitted it and reported "never mounted" — a harness gap, not the RTL defect. -->
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";<\/script>
<script src="${TREE}/doxa-maps/${c.id}/${c.id}.js" crossorigin="anonymous"><\/script>
<script>
var el = document.createElement(${JSON.stringify(c.tag)});
el.setAttribute('profile-config', JSON.stringify({ profile: ${JSON.stringify(c.profile)}, locale: 'ar',
assetsBase: ${JSON.stringify(TREE + '/doxa-maps/assets')} }));
el.style.cssText = 'display:block;width:100%;height:900px';
document.getElementById('w').appendChild(el);
<\/script></body></html>`
// ⚠ THE TREE NEEDS CORS HEADERS OR THE SCRIPT NEVER LOADS. A partner embed uses
// crossorigin="anonymous" (SRI requires it), so the browser demands
// Access-Control-Allow-Origin from the tree's origin. `bun run serve` does not send it, and the
// first runs of this gate reported "the element never mounted" — which was MY HARNESS, not the
// product: a real CDN sends the header. So proxy the tree through our own CORS-enabled port.
const proxy = createServer(async (req, res) => {
try {
const up = await fetch(BASE + req.url)
const body = Buffer.from(await up.arrayBuffer())
res.writeHead(up.status, {
'content-type': up.headers.get('content-type') || 'application/octet-stream',
'access-control-allow-origin': '*',
})
res.end(body)
} catch { res.writeHead(502); res.end('upstream') }
})
await new Promise((r) => proxy.listen(TREE_PORT, '127.0.0.1', r))
const srv = createServer((req, res) => {
const which = (req.url || '').replace(/^\/+/, '').split('?')[0] || '0'
const c = CASES[Number(which)] || CASES[0]
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(hostHtml(c))
})
await new Promise((r) => srv.listen(HOST_PORT, '127.0.0.1', r))
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const WALK = `(root)=>{const o=[];const rec=(r)=>{for(const e of r.querySelectorAll('*')){o.push(e);if(e.shadowRoot)rec(e.shadowRoot)}};rec(root);return o}`
const rows = []
const fails = []
for (let i = 0; i < CASES.length; i++) {
const c = CASES[i]
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await page.goto(`http://127.0.0.1:${HOST_PORT}/${i}`, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(17000)
const r = await page.evaluate(`(() => {
const all = (${WALK})(document)
const host = document.querySelector('${c.tag}')
const sr = host && host.shadowRoot
const root = sr && sr.querySelector('.profile-loader')
const dirOf = (e) => { if (!e) return null
const own = e.getAttribute && e.getAttribute('dir'); if (own) return own
try { return getComputedStyle(e).direction } catch { return null } }
const arabic = (s) => (String(s).match(/[\\u0600-\\u06FF]/g) || []).length
const chromeText = all.filter((e) => /\\b(pbtn|tape-h|seg|wall-h|wall-tag)\\b/.test(e.className || '')
|| e.tagName === 'LEGEND')
.map((e) => (e.textContent || '').trim()).join(' ')
return {
partnerHtmlDir: document.documentElement.getAttribute('dir'),
partnerBodyDir: getComputedStyle(document.body).direction,
partnerHeadingDir: getComputedStyle(document.getElementById('partner-heading')).direction,
elementDir: dirOf(host),
pluginRootDir: dirOf(root),
arabicChrome: arabic(chromeText),
mounted: !!sr,
}
})()`)
await page.close()
rows.push({ profile: c.profile, ...r, errs: errs.length })
if (!r.mounted) { fails.push(`${c.profile}: the element never mounted — nothing to judge`); continue }
// The element must not be judged on a page where the locale never took effect.
if (!r.arabicChrome) { fails.push(`${c.profile}: 0 Arabic chrome — the locale did not apply, so direction cannot be assessed`); continue }
if (r.partnerHtmlDir === 'rtl' || r.partnerBodyDir === 'rtl' || r.partnerHeadingDir === 'rtl') {
fails.push(`${c.profile}: THE PARTNER'S PAGE WENT RTL (html=${r.partnerHtmlDir}, body=${r.partnerBodyDir}, heading=${r.partnerHeadingDir}) — an embedded widget must never flip its host`)
}
if (r.pluginRootDir !== 'rtl' && r.elementDir !== 'rtl') {
fails.push(`${c.profile}: the plugin did NOT go RTL (element=${r.elementDir}, root=${r.pluginRootDir}) while its chrome is Arabic (${r.arabicChrome} chars)`)
}
if (errs.length) fails.push(`${c.profile}: ${errs.length} page error(s) — ${errs[0]}`)
}
await browser.close(); srv.close(); proxy.close()
console.log('EMBEDDED-RTL')
console.log(` tree : ${BASE}`)
console.log(` partner: http://127.0.0.1:${HOST_PORT} (foreign origin, <html lang="en"> no dir)`)
for (const r of rows) {
console.log(` ${r.profile} element dir ${r.elementDir ?? '-'} · plugin root ${r.pluginRootDir ?? '-'} · `
+ `partner html ${r.partnerHtmlDir ?? 'unset'} / body ${r.partnerBodyDir} · arabic ${r.arabicChrome} · ${r.errs} err`)
}
if (fails.length) {
console.error(`\nEMBEDDED-RTL: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
process.exit(1)
}
console.log('\nEMBEDDED-RTL: PASS — the plugin goes RTL and the partner page does not.')