mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" — because neither GL engine loads its optional RTL text plugin by default and TREK never registered one. Raster basemaps hid the problem, since their labels are drawn server-side, so it only became visible for everyone when the default basemap became a vector style. Both engine entry points now register the plugin lazily, so the payload is only fetched once a map actually paints RTL text and Latin-only users download nothing. The call is left with each engine because the two signatures genuinely differ: MapLibre takes (url, lazy) and returns a promise, Mapbox takes (url, callback, deferred). The plugin is bundled rather than loaded from a CDN: TREK is offline-first and the production CSP is script-src 'self', so a remote plugin URL would be refused by the worker that imports it. Bundling also puts it in the Workbox precache, so shaping keeps working offline. It needs a resolve alias because the package publishes only its ESM/WASM entry point through "exports" while setRTLTextPlugin requires the classic script in dist/ — the alias is shared by the build and test configs so the two cannot drift.
This commit is contained in:
committed by
Maurice
parent
6f057ab9f4
commit
dc7d1f5e10
@@ -37,6 +37,7 @@
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
|
||||
* with importScripts. mapbox-gl-rtl-text ships exactly that as
|
||||
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
|
||||
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
|
||||
* and stepping across to dist keeps this working wherever npm hoists the package.
|
||||
*
|
||||
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
|
||||
* asset, and the test run needs it because a lazily imported engine module reaches
|
||||
* the same specifier.
|
||||
*
|
||||
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
|
||||
* which a string alias will not match. It is left unanchored on purpose so the
|
||||
* query survives the rewrite and Vite still emits an asset URL.
|
||||
*/
|
||||
export const rtlTextAlias = {
|
||||
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
|
||||
'../dist/mapbox-gl-rtl-text.js',
|
||||
),
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls mapbox-gl in at runtime.
|
||||
@@ -8,4 +10,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
* a static import from anywhere in the tree drags 1.8 MB into the parent chunk and
|
||||
* quietly undoes the split. `npm run check:gl-split` fails on that.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// Mapbox's signature is (url, callback, deferred) — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => mapboxgl.setRTLTextPlugin(rtlTextPluginUrl, null, true))
|
||||
|
||||
export default mapboxgl
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -10,4 +12,8 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { registerRtlTextPlugin } from './rtlText'
|
||||
|
||||
describe('registerRtlTextPlugin', () => {
|
||||
it('runs the engine-supplied registration', () => {
|
||||
const register = vi.fn()
|
||||
|
||||
expect(registerRtlTextPlugin(register)).toBe(true)
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Registering twice is how the engines report it: Mapbox throws.
|
||||
it('never lets a refused registration break map creation', () => {
|
||||
const register = vi.fn(() => {
|
||||
throw new Error('setRTLTextPlugin cannot be called multiple times.')
|
||||
})
|
||||
|
||||
expect(() => registerRtlTextPlugin(register)).not.toThrow()
|
||||
expect(registerRtlTextPlugin(register)).toBe(false)
|
||||
})
|
||||
|
||||
// MapLibre reports the same by rejecting, and an unhandled rejection would
|
||||
// reach the console for something the user cannot act on.
|
||||
it('swallows a rejected registration promise', async () => {
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(registerRtlTextPlugin(() => Promise.reject(new Error('404')))).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a resolved promise alone', async () => {
|
||||
expect(registerRtlTextPlugin(() => Promise.resolve())).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Arabic, Hebrew, Persian and Urdu labels need two things a GL engine does not do
|
||||
* on its own: the bidirectional algorithm, and Arabic contextual shaping (a letter
|
||||
* is drawn differently depending on its neighbours). Without them the engine lays
|
||||
* the codepoints out one by one, left to right, in isolated forms — so القاهرة
|
||||
* comes out as ة ر ه ا ق ل ا, unjoined and reversed. Raster basemaps never showed
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
* and both are handled below, so the probe bought nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a GL engine's RTL-plugin registration, swallowing the ways it can fail.
|
||||
*
|
||||
* Callers register lazily, so the payload is only fetched once a map actually
|
||||
* paints RTL text and a user who never leaves Latin script downloads nothing.
|
||||
*
|
||||
* @returns whether the registration call completed without throwing.
|
||||
*/
|
||||
export function registerRtlTextPlugin(register: () => void | Promise<unknown>): boolean {
|
||||
try {
|
||||
const pending = register()
|
||||
// MapLibre reports a failed or duplicate registration by rejecting; Mapbox
|
||||
// routes the same through its callback. Neither is worth surfacing: a map
|
||||
// with unshaped labels still renders, and an unhandled rejection would not
|
||||
// give the user anything to act on.
|
||||
if (pending instanceof Promise) pending.catch(() => {})
|
||||
return true
|
||||
} catch {
|
||||
// Already registered, or the engine refused outright. Registering the plugin
|
||||
// must never be able to break map creation.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { rtlTextAlias } from './rtlTextAlias.js';
|
||||
|
||||
// `npm run build:analyze` writes dist/stats.html — a treemap of what actually ended
|
||||
// up in each chunk. The plain build only reports chunk sizes, which tells you a chunk
|
||||
@@ -227,6 +228,7 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
}),
|
||||
].filter(Boolean),
|
||||
resolve: { alias: [rtlTextAlias] },
|
||||
build: {
|
||||
// Pin the output level instead of inheriting whatever the current Vite default
|
||||
// is, so a toolchain bump can't silently change which browsers still work.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { rtlTextAlias } from './rtlTextAlias.js';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
alias: [rtlTextAlias],
|
||||
root: '.',
|
||||
globals: true,
|
||||
environment: './tests/environment/jsdom-native-abort.ts',
|
||||
|
||||
Generated
+7
@@ -27,6 +27,7 @@
|
||||
"name": "@trek/client",
|
||||
"version": "4.1.1",
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
@@ -4699,6 +4700,12 @@
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-rtl-text": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.4.0.tgz",
|
||||
"integrity": "sha512-WNSgEFxXd2a15Eh2GwZNWBiV7Z8leq8JEGcoHpfrgaBDmFQMW4a1MO5Ol9BNv6APqTbHZ8uq7L8H5q2KCVt1kg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
|
||||
Reference in New Issue
Block a user