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.
153 lines
8.3 KiB
JavaScript
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.')
|