doxa-maps/internal/check-embedded-rtl.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

153 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'
import { TREE_NAME } from './_tree-name.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}/${TREE_NAME}/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}/${TREE_NAME}/${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 + `/${TREE_NAME}/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.')