A map says how it behaves on a narrow screen once, in its <profile>.meta.json: "mobile": "desktop-only" (blocked on a phone) or "larger-screens" (opens, under a dismissible notice). Absent means phone-ready. Until now the rule lived only in the Research Gallery, as two hard-coded id sets, so a map opened standalone on its own leaf page on a phone got neither the block nor the notice. - profile-meta.mjs knows the vocabulary; generate-tree and the dev manifest carry the flag into each profile's manifest entry, and generate-tree into the leaf page's BASE_CONFIG (with the map's name for the desktop-only sentence). - ProfileLoader mounts library/components/LargerScreenNotice.vue on a screen under 640px: for desktop-only the profile is never mounted and a centred message says why; for larger-screens a slim in-flow bar sits above the map (it cannot cover the map's controls), dismissible per profile for the session, in the element's own locale and direction. Inside the Gallery it stays quiet: every Gallery pane now carries data-doxa-gallery. - One text: the Gallery's two sentences moved into the library catalogue as messages.largerScreenNotice / desktopOnlyNotice (+ noticeDismiss), all 11 locales moved as they were, and the Gallery reads them from there. - The Gallery derives its rules from the manifest's flags. On a phone it mounts nothing until the manifest has landed: its baked first tab (Big Picture) would otherwise mount there before the flags say it is desktop-only. - dashboard-plus-grid is desktop-only and gospel-internet/board is larger-screens, the two sets the Gallery held; nothing changes for either. - check:mobile-gating now asserts the vocabulary, that each flagged leaf page carries its flag, the Gallery's derivation and its closed-before-manifest rule, that Big Picture stays desktop-only, the 11 locales, and the notice's mounting. Its self-test plants one failure per rule.
162 lines
10 KiB
JavaScript
162 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-mobile-gating.mjs — one phone flag per map, and every surface follows it (SPEC-MOBILE-FLAG,
|
|
* the per-plugin half of DRV1650-MOBILE-GATING).
|
|
*
|
|
* A map declares how it behaves on a narrow screen ONCE, in <profile>.meta.json:
|
|
* "mobile": "desktop-only" blocked on a phone (the Gallery disables its row; the leaf shows why)
|
|
* "mobile": "larger-screens" opens on a phone, under a dismissible notice
|
|
* absent phone-ready
|
|
* generate-tree carries it into the manifest and the leaf page's BASE_CONFIG; the Research Gallery
|
|
* derives its rules from the manifest; ProfileLoader shows the leaf's notice. This fails if:
|
|
* (a) a meta.json declares a value outside that vocabulary (a typo must not read as phone-ready);
|
|
* (b) a flagged profile's leaf page does not carry the flag, or a leaf carries one its meta dropped;
|
|
* (c) the Gallery hard-codes an id set again, stops deriving from the manifest, or stops failing
|
|
* closed on a phone before the manifest lands (its baked first tab would mount there);
|
|
* (d) dashboard-plus-grid stops being desktop-only — the migration must not soften that gate;
|
|
* (e) a locale loses one of the notice sentences, or the desktop-only one stops naming the map;
|
|
* (f) ProfileLoader stops mounting the notice, or stops staying quiet inside the Gallery.
|
|
* `--self-test` plants one bad case per rule and proves each can fail before a pass is trusted.
|
|
*/
|
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { MOBILE } from './profile-meta.mjs'
|
|
import { TREE_NAME } from './_tree-name.mjs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const SPA = join(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.vue')
|
|
const STRINGS = join(ROOT, 'plugin-designs/doxa-maps-spa/src/strings.js')
|
|
const LOADER = join(ROOT, 'library/ProfileLoader.vue')
|
|
const SPA_KEYS = ['mobileNotBuilt', 'dismiss']
|
|
const LIB_KEYS = ['largerScreenNotice', 'desktopOnlyNotice', 'noticeDismiss']
|
|
|
|
export function checkGating({ metas, leaves, spaSrc, spaLocales, libLocales, loaderSrc }) {
|
|
const errs = []
|
|
// (a) the vocabulary
|
|
for (const m of metas) {
|
|
if (m.mobile !== undefined && !MOBILE.includes(m.mobile)) errs.push(`${m.bundle}/${m.profile}.meta.json: mobile "${m.mobile}" is not one of ${MOBILE.join(' | ')}`)
|
|
}
|
|
// (b) the leaf pages carry exactly what the metas declare
|
|
for (const m of metas) {
|
|
const key = `${m.bundle}/${m.profile}`
|
|
if (!(key in leaves)) continue // no leaf page in this tree: nothing to carry
|
|
const got = leaves[key] ? leaves[key].mobile : undefined
|
|
const want = MOBILE.includes(m.mobile) ? m.mobile : undefined
|
|
if (got !== want) errs.push(`${key}: the leaf page says mobile ${JSON.stringify(got)}, its meta.json says ${JSON.stringify(want)} (regenerate the tree)`)
|
|
}
|
|
// (c) the Gallery derives, and fails closed
|
|
if (/const (DESKTOP_ONLY|MOBILE_NOTICE) = new Set\(/.test(spaSrc)) errs.push('the Gallery hard-codes a DESKTOP_ONLY / MOBILE_NOTICE id set again; the flag lives in meta.json')
|
|
if (!/this\.mobileFlags = mobileFlagsFromManifest\(m\)/.test(spaSrc)) errs.push('applyManifest no longer records the flags from the manifest')
|
|
if (!/isDesktopOnly\(id\)\s*\{\s*return this\.narrow && this\.mobileFlagOf\(id\) === 'desktop-only'/.test(spaSrc)) errs.push("isDesktopOnly no longer reads narrow + the 'desktop-only' flag")
|
|
if (!/showsMobileNotice\(id\)\s*\{\s*return this\.narrow && this\.mobileFlagOf\(id\) === 'larger-screens'/.test(spaSrc)) errs.push("showsMobileNotice no longer reads narrow + the 'larger-screens' flag")
|
|
if (!/if \(this\.narrow && this\.mobileFlags === null\) return/.test(spaSrc)) errs.push('select() no longer waits for the flags on a phone: the baked first tab would mount before the manifest says it is desktop-only')
|
|
if (!/v-if="showsMobileNotice\(t\.id\)"[^>]*role="status"/.test(spaSrc)) errs.push('the pane notice (showsMobileNotice, role=status) is not rendered')
|
|
if (!/data-doxa-gallery=""/.test(spaSrc)) errs.push('Gallery panes no longer carry data-doxa-gallery: a map inside would show a second notice')
|
|
// (d) the one gate the migration must not soften
|
|
const dpg = metas.find((m) => m.bundle === 'dashboard-plus-grid' && m.profile === 'dashboard-plus-grid')
|
|
if (!dpg || dpg.mobile !== 'desktop-only') errs.push('dashboard-plus-grid is no longer "mobile": "desktop-only" (Big Picture is blocked on phones)')
|
|
// (e) the sentences
|
|
for (const [loc, t] of Object.entries(spaLocales || {})) {
|
|
for (const k of SPA_KEYS) if (!t || !String(t[k] || '').trim()) errs.push(`Gallery strings ${loc}: missing ${k}`)
|
|
}
|
|
if (Object.keys(spaLocales || {}).length < 11) errs.push(`Gallery strings: ${Object.keys(spaLocales || {}).length} locales, expected 11`)
|
|
for (const [loc, t] of Object.entries(libLocales || {})) {
|
|
for (const k of LIB_KEYS) if (!t || !String(t[k] || '').trim()) errs.push(`library messages ${loc}: missing ${k}`)
|
|
if (t && t.desktopOnlyNotice && !t.desktopOnlyNotice.includes('{label}')) errs.push(`library messages ${loc}: desktopOnlyNotice does not name the map ({label})`)
|
|
}
|
|
if (Object.keys(libLocales || {}).length < 11) errs.push(`library messages: ${Object.keys(libLocales || {}).length} locales, expected 11`)
|
|
// (f) the leaf's own notice
|
|
if (!/<LargerScreenNotice v-else-if="blockedOnThisScreen"/.test(loaderSrc)) errs.push('ProfileLoader no longer replaces a desktop-only profile with the notice on a phone')
|
|
if (!/<LargerScreenNotice v-if="adviseOnThisScreen"/.test(loaderSrc)) errs.push('ProfileLoader no longer shows the larger-screens notice')
|
|
if (!/hasAttribute\('data-doxa-gallery'\)/.test(loaderSrc)) errs.push('ProfileLoader no longer stays quiet inside the Gallery (data-doxa-gallery)')
|
|
return errs
|
|
}
|
|
|
|
function readMetas() {
|
|
const out = []
|
|
const base = join(ROOT, 'plugin-designs')
|
|
for (const bundle of readdirSync(base)) {
|
|
let files = []
|
|
try { files = readdirSync(join(base, bundle)).filter((f) => f.endsWith('.meta.json')) } catch { continue }
|
|
for (const f of files) {
|
|
try {
|
|
const meta = JSON.parse(readFileSync(join(base, bundle, f), 'utf8'))
|
|
out.push({ bundle, profile: f.replace(/\.meta\.json$/, ''), mobile: meta.mobile })
|
|
} catch { /* an unreadable meta is other gates' business */ }
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A leaf page's BASE_CONFIG, or null when the page has none. Keyed "bundle/profile"; only pages that exist.
|
|
function readLeaves(metas) {
|
|
const out = {}
|
|
for (const m of metas) {
|
|
const page = join(ROOT, 'public', TREE_NAME, m.bundle, m.profile, 'index.html')
|
|
if (!existsSync(page)) continue
|
|
const hit = readFileSync(page, 'utf8').match(/var BASE_CONFIG = (\{.*?\});/)
|
|
try { out[`${m.bundle}/${m.profile}`] = hit ? JSON.parse(hit[1]) : null } catch { out[`${m.bundle}/${m.profile}`] = null }
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function readSpaLocales() {
|
|
const mod = await import(pathToFileURL(STRINGS).href)
|
|
return Object.values(mod).find((v) => v && typeof v === 'object' && v.en) || {}
|
|
}
|
|
|
|
function readLibLocales() {
|
|
const out = {}
|
|
const base = join(ROOT, 'library/i18n/locales')
|
|
for (const lc of readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)) {
|
|
try { out[lc] = JSON.parse(readFileSync(join(base, lc, 'messages.json'), 'utf8')) } catch { out[lc] = null }
|
|
}
|
|
return out
|
|
}
|
|
|
|
const metas = readMetas()
|
|
const real = {
|
|
metas,
|
|
leaves: readLeaves(metas),
|
|
spaSrc: readFileSync(SPA, 'utf8'),
|
|
spaLocales: await readSpaLocales(),
|
|
libLocales: readLibLocales(),
|
|
loaderSrc: readFileSync(LOADER, 'utf8'),
|
|
}
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
const flagged = real.metas.find((m) => m.mobile && `${m.bundle}/${m.profile}` in real.leaves)
|
|
const withMeta = (fn) => ({ ...real, metas: real.metas.map((m) => fn({ ...m })) })
|
|
const cases = [
|
|
['a clean tree passes', real, 0],
|
|
['(a) a typo in the vocabulary', withMeta((m) => (m.bundle === 'gospel-internet' && m.profile === 'board' ? { ...m, mobile: 'larger-screen' } : m)), 1],
|
|
['(b) a flagged leaf page without the flag', flagged ? { ...real, leaves: { ...real.leaves, [`${flagged.bundle}/${flagged.profile}`]: {} } } : null, 1],
|
|
['(b) a leaf page carrying a flag its meta dropped', flagged ? withMeta((m) => (m === flagged || (m.bundle === flagged.bundle && m.profile === flagged.profile) ? { ...m, mobile: undefined } : m)) : null, 1],
|
|
['(c) the id set comes back', { ...real, spaSrc: real.spaSrc + "\nconst DESKTOP_ONLY = new Set(['dashboard-plus-grid'])" }, 1],
|
|
['(c) select() stops waiting for the flags', { ...real, spaSrc: real.spaSrc.replace('if (this.narrow && this.mobileFlags === null) return', '') }, 1],
|
|
['(c) the pane notice is removed', { ...real, spaSrc: real.spaSrc.replace('v-if="showsMobileNotice(t.id)"', 'v-if="false"') }, 1],
|
|
['(d) Big Picture is softened', withMeta((m) => (m.bundle === 'dashboard-plus-grid' ? { ...m, mobile: 'larger-screens' } : m)), 1],
|
|
['(e) a locale loses the notice', { ...real, libLocales: { ...real.libLocales, ar: { ...real.libLocales.ar, largerScreenNotice: '' } } }, 1],
|
|
['(e) the desktop-only sentence stops naming the map', { ...real, libLocales: { ...real.libLocales, zh: { ...real.libLocales.zh, desktopOnlyNotice: '仅支持桌面端' } } }, 1],
|
|
['(f) ProfileLoader stops blocking', { ...real, loaderSrc: real.loaderSrc.replace('v-else-if="blockedOnThisScreen"', 'v-else-if="false"') }, 1],
|
|
]
|
|
let ok = true
|
|
for (const [name, input, want] of cases) {
|
|
if (!input) { console.log(` - ${name}: no flagged leaf page in this tree to plant it on`); ok = false; continue }
|
|
const n = checkGating(input).length
|
|
const pass = want === 0 ? n === 0 : n >= want
|
|
if (!pass) ok = false
|
|
console.log(` ${pass ? '✓' : '✗'} ${name} -> ${n} error(s) (want ${want === 0 ? 0 : '≥' + want})`)
|
|
}
|
|
console.log(ok ? 'MOBILE-GATING SELF-TEST: PASS — every rule proven able to fail' : 'MOBILE-GATING SELF-TEST: FAIL')
|
|
process.exit(ok ? 0 : 1)
|
|
}
|
|
|
|
const errs = checkGating(real)
|
|
if (errs.length) {
|
|
console.error('MOBILE-GATING: FAIL\n' + errs.map((e) => ' ✗ ' + e).join('\n'))
|
|
process.exit(1)
|
|
}
|
|
const list = (flag) => real.metas.filter((m) => m.mobile === flag).map((m) => `${m.bundle}/${m.profile}`).join(', ') || '(none)'
|
|
console.log(`MOBILE-GATING: PASS — desktop-only: ${list('desktop-only')} · larger-screens: ${list('larger-screens')} · leaf pages carry the flags · 11 locales`)
|