The console showed 24 Vue warnings, 8 each for "profileModules", "appI18n" and "Symbol(pinia)" — "already provides property with key … inherited from its parent element. It will be overwritten with the new value." They come from a composite mounting child custom elements: Vue's CE layer lets a child app inherit its parent ELEMENT's provides, and app.provide() warns on any key already reachable. ALL THREE MUST BE THE CHILD'S OWN, so neither silencing the warning nor honouring it by not providing would have been right. profileModules is the plugin's own .vue glob — a child rendering its parent's components is a different and worse bug. pinia is per-instance so two embeds on one page never bleed store state. And the i18n instance is the one ProfileLoader drives from THIS element's profile-config, which is the whole of the permanently-English fix from earlier today. Inheriting any of the three would be the regression, not the warning. So the shadow is made DELIBERATE at the line that does it. provideOwn() writes to the app's own provides when the key is already reachable and uses the public app.provide() when it is not, so the ordinary path is untouched. pinia needs the other shape: its symbol is module-private, so it can only be provided by app.use(), and the key cannot be named — withoutInheritedProvides() hides the inherited chain for the duration of the install and restores it in a FINALLY, so a throwing install cannot leave an element unable to see its parent. The helpers are split into library/elements/provide-own.js with NO framework import, because defineMapElement.js imports ProfileLoader.vue and is therefore unloadable by the gate that proves it right. Third time today that split has been necessary; the gate now exercises the shipped helpers rather than a copy. check-provide-shadowing asserts BOTH halves — the child ends up with its own value, AND the parent's other provides still resolve afterwards — against a Vue-shaped fake app that warns exactly as runtime-core does. Its control is the naive call, which must still warn. Negative-tested three ways, including moving the restore out of the finally. check-element-bootstrap now accepts either spelling and was re-checked for the thing it actually guards: removing the provide entirely still turns it red.
114 lines
7 KiB
JavaScript
114 lines
7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-element-bootstrap — every plugin that ships a custom element must build it the same way.
|
|
*
|
|
* ★ THE BUG THIS EXISTS FOR WAS LIVE IN FIVE PLUGINS AT ONCE. A profile element is
|
|
* defineCustomElement(ProfileLoader, { configureApp }) and the configureApp has four steps; the fourth,
|
|
* `app.provide('appI18n', i18n)`, is the one that gets dropped. ProfileLoader injects that instance
|
|
* (ProfileLoader.vue:176) to drive its locale from profile-config, so without it the drive never runs
|
|
* and every useI18n()/t() string is stuck at the boot locale — while setRuntimeLocale() on the next line
|
|
* still fires, so tStatic strings DO follow the host. The element renders a convincing MIXTURE of the
|
|
* reader's language and English.
|
|
*
|
|
* Nothing throws, because the t() shim falls back to its key. So it reads as "a few strings not
|
|
* translated yet" and it survived in country-posters, key-cities, gospel-resources, cities-internet and
|
|
* my-upg-100-list simultaneously — confirmed independently by regions-plugin, who censused all 19 entry
|
|
* files and found the same five and no sixth.
|
|
*
|
|
* ★ AND A CENSUS THAT COUNTED FUNCTION BODIES SAW FOUR OF SIXTEEN. That is why this gate greps for the
|
|
* SHAPE (`defineCustomElement(ProfileLoader`) rather than for a known list: eleven of the sites are not
|
|
* `function buildXElement()` at all, they are `const El = defineCustomElement(...)`, and a check written
|
|
* around the four that matched would have passed the twelve that did not.
|
|
*
|
|
* TWO ARMS:
|
|
* A. no plugin entry may call defineCustomElement(ProfileLoader, …) directly — use the helper;
|
|
* B. the helper itself must provide appI18n, PLANTED as a negative case, so the regression cannot
|
|
* return by editing library instead of a plugin.
|
|
*
|
|
* Exit 0 pass · 1 fail.
|
|
*/
|
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const TAG = 'ELEMENT-BOOTSTRAP'
|
|
const HELPER = 'library/elements/defineMapElement.js'
|
|
const fails = []
|
|
|
|
// Stated exemptions. An element that wraps its OWN root component rather than ProfileLoader is a
|
|
// different thing and is not in scope: it has no ProfileLoader to inject anything.
|
|
const EXEMPT = new Map([])
|
|
|
|
// ── ARM A: no entry builds a profile element by hand ─────────────────────────────────────────────
|
|
const PD = join(ROOT, 'plugin-designs')
|
|
let entries = 0, viaHelper = 0
|
|
for (const p of readdirSync(PD)) {
|
|
const f = join(PD, p, 'index.js')
|
|
if (!existsSync(f)) continue
|
|
const src = readFileSync(f, 'utf8')
|
|
const buildsProfileElement = /defineCustomElement\s*\(\s*ProfileLoader/.test(src)
|
|
const usesHelper = /defineMapElement\s*\(/.test(src)
|
|
if (!buildsProfileElement && !usesHelper) continue
|
|
entries++
|
|
if (usesHelper) viaHelper++
|
|
// ★ CALLED IS NOT IMPORTED. The first version of this migration called defineMapElement in eleven
|
|
// entries that never imported it: a build does not fail on an unbound name, only the element's
|
|
// first import does (ReferenceError), so the shape check above stayed green while eleven plugins
|
|
// could not load. The landing precondition caught it by importing the profiles.
|
|
if (usesHelper && !/import\s*\{[^}]*\bdefineMapElement\b[^}]*\}\s*from\s*['"][^'"]*defineMapElement(\.js)?['"]/.test(src)) {
|
|
fails.push(`${p}/index.js calls defineMapElement(…) but never imports it: the element throws\n`
|
|
+ ` "defineMapElement is not defined" the moment it loads.`)
|
|
}
|
|
if (buildsProfileElement) {
|
|
if (EXEMPT.has(p)) continue
|
|
fails.push(`${p}/index.js calls defineCustomElement(ProfileLoader, …) directly.\n`
|
|
+ ` Use ${HELPER}. Building it by hand is how five plugins shipped without\n`
|
|
+ " provide('appI18n') and rendered a mixture of the reader's language and English.")
|
|
}
|
|
}
|
|
if (entries < 10) {
|
|
fails.push(`only ${entries} profile-element entries found — the detection stopped matching and this `
|
|
+ 'gate would pass while checking almost nothing.')
|
|
}
|
|
|
|
// ── ARM B: the helper provides appI18n, proved by PLANTING its absence ───────────────────────────
|
|
const helperSrc = existsSync(join(ROOT, HELPER)) ? readFileSync(join(ROOT, HELPER), 'utf8') : ''
|
|
if (!helperSrc) {
|
|
fails.push(`${HELPER} does not exist — every entry above is pointing at nothing.`)
|
|
} else {
|
|
// ★ COMMENTS STRIPPED FIRST, and this gate's own negative case is what caught the need. The helper's
|
|
// docstring QUOTES the provide line in prose, so a predicate over the raw source matched the
|
|
// documentation and reported the code present even after the code was removed — it would have passed
|
|
// a helper that only talked about providing appI18n. A check that reads comments is reading the
|
|
// author's intentions, not the program.
|
|
const code = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
// Either spelling counts: app.provide('appI18n', …) or provideOwn(app, 'appI18n', …). The second
|
|
// exists because a NESTED element must shadow its parent's instance deliberately (see
|
|
// library/elements/provide-own.js); what this gate cares about is that the instance reaches
|
|
// ProfileLoader at all, not which function put it there.
|
|
const provides = (s) => /(?:app\.provide\(\s*|provideOwn\(\s*app\s*,\s*)['"]appI18n['"]\s*,/.test(code(s))
|
|
const installs = (s) => /app\.use\(\s*i18n\s*\)|app\.use\(\s*createAppI18n\(\)\s*\)/.test(code(s))
|
|
if (!provides(helperSrc)) {
|
|
fails.push(`${HELPER} installs i18n but never provides it. ProfileLoader injects 'appI18n' to drive\n`
|
|
+ ' the locale; without the provide the element is permanently at its boot locale, silently.')
|
|
}
|
|
if (!installs(helperSrc)) fails.push(`${HELPER} does not install an i18n instance at all.`)
|
|
// THE PLANT: the same predicate, run against a bootstrap with the provide removed, MUST report it.
|
|
// /gm, not /m: the docstring quotes the line too, so removing only the FIRST match deleted the
|
|
// DOCUMENTATION and left the code — the plant then "failed to detect" a provide that was still there.
|
|
// Caught by this assertion failing on a correct helper, which is the plant doing its job on itself.
|
|
const planted = helperSrc.replace(/^.*(?:app\.provide\(\s*|provideOwn\(\s*app\s*,\s*)['"]appI18n['"].*$/gm, '')
|
|
if (provides(planted)) {
|
|
fails.push('the check for the provide cannot detect its absence — it is measuring nothing. '
|
|
+ 'A gate that passes its own negative case is worse than no gate.')
|
|
}
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error(`${TAG}: FAIL`)
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`${TAG}: PASS — ${viaHelper}/${entries} profile elements are built through ${HELPER}, which provides `
|
|
+ "appI18n; an element built without it is detected, so the 'permanently English' regression cannot return quietly.")
|