blob: 044be9f720949743147fe61312a1329b14c7cbeb [file]
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Resolves a reference downstream's configuration against the current tree.
*
* `DOCS/examples/branding-config.example.json` stands in for a real deployment: an invented bank
* that hides sections, renames entries, retints the palette and adds a group of its own. It is
* deliberately maximal, because the promise being tested is that such a deployment takes an
* upstream release as a version bump.
*
* Nothing else catches this. The unit tests use a fixture tree of three entries, and the e2e
* suite mocks whatever overlay each case needs — so both keep passing while an id the real
* example names quietly disappears from NAV_CONFIG. What breaks is the downstream, one release
* later, with a menu that silently stopped matching.
*
* Checked here, against the shipped surface rather than a fixture:
*
* - every nav id named by `hidden`, `overrides` or a `parent` resolves
* - every added id is free, so nothing shadows a built-in entry
* - every token named is on the published allow-list
* - every colour clears the contrast floor the runtime enforces
* - every external entry has an http(s) url
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const EXAMPLE = join(ROOT, 'DOCS/examples/branding-config.example.json');
const NAV_IDS = join(HERE, 'nav-ids.json');
const BRANDING_SERVICE = join(ROOT, 'src/app/core/services/branding.service.ts');
/** WCAG 2.1 relative luminance. Mirrors the arithmetic in branding.service.ts. */
function luminance(hex) {
return [1, 3, 5]
.map((at) => parseInt(hex.slice(at, at + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
.reduce((sum, c, i) => sum + [0.2126, 0.7152, 0.0722][i] * c, 0);
}
const contrastWithWhite = (hex) => 1.05 / (luminance(hex) + 0.05);
/** The allow-list and the floor are read from the service, never restated, so they cannot drift. */
function readBrandingContract() {
const source = readFileSync(BRANDING_SERVICE, 'utf8');
const listBody = /export const BRANDABLE_TOKENS: readonly string\[\] = \[([\s\S]*?)\];/.exec(
source,
);
if (!listBody) throw new Error('BRANDABLE_TOKENS not found in branding.service.ts');
const tokens = [...listBody[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
const floor = /export const MIN_PRIMARY_CONTRAST = ([\d.]+);/.exec(source);
if (!floor) throw new Error('MIN_PRIMARY_CONTRAST not found in branding.service.ts');
const names = (label) => {
const block = new RegExp(`const ${label} = new Set\\(\\[([\\s\\S]*?)\\]\\);`).exec(source);
return new Set(block ? [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]) : []);
};
return {
tokens: new Set(tokens),
floor: Number(floor[1]),
requiresWhite: names('REQUIRES_WHITE_TEXT'),
};
}
/** Every id the example's added items introduce, including nested children. */
function collectAddedIds(items, into = []) {
for (const item of items ?? []) {
if (item.id) into.push(item.id);
collectAddedIds(item.children, into);
}
return into;
}
function* walkItems(items) {
for (const item of items ?? []) {
yield item;
yield* walkItems(item.children);
}
}
const example = JSON.parse(readFileSync(EXAMPLE, 'utf8'));
const shippedIds = new Set(JSON.parse(readFileSync(NAV_IDS, 'utf8')).ids);
const { tokens: brandable, floor, requiresWhite } = readBrandingContract();
const problems = [];
const nav = example.nav ?? {};
const addedIds = collectAddedIds(nav.items);
const knownIds = new Set([...shippedIds, ...addedIds]);
for (const id of nav.hidden ?? []) {
if (!shippedIds.has(id)) {
problems.push(`nav.hidden names "${id}", which is not a nav id in this tree.`);
}
}
for (const id of Object.keys(nav.overrides ?? {})) {
if (!knownIds.has(id)) {
problems.push(`nav.overrides names "${id}", which is not a nav id in this tree.`);
}
}
for (const [id, patch] of Object.entries(nav.overrides ?? {})) {
if (patch.parent && !knownIds.has(patch.parent)) {
problems.push(`nav.overrides["${id}"].parent names "${patch.parent}", which does not exist.`);
}
}
for (const id of addedIds) {
if (shippedIds.has(id)) {
problems.push(`nav.items adds "${id}", which shadows a built-in entry. Prefix deployment ids.`);
}
}
const seenAdded = new Set();
for (const id of addedIds) {
if (seenAdded.has(id)) problems.push(`nav.items uses "${id}" more than once.`);
seenAdded.add(id);
}
for (const item of walkItems(nav.items)) {
if (item.parent && !knownIds.has(item.parent)) {
problems.push(`nav.items["${item.id}"].parent names "${item.parent}", which does not exist.`);
}
if (item.kind === 'external' && !/^https?:\/\//i.test(item.url ?? '')) {
problems.push(`nav.items["${item.id}"] is external but has no http(s) url.`);
}
}
const tokenSets = example.branding?.tokens ?? {};
for (const [mode, set] of Object.entries(tokenSets)) {
for (const [name, value] of Object.entries(set ?? {})) {
if (!brandable.has(name)) {
problems.push(`branding.tokens.${mode} sets "${name}", which is not a brandable token.`);
continue;
}
if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) continue;
const hex =
value.length === 4
? `#${value
.slice(1)
.split('')
.map((d) => d + d)
.join('')}`
: value.toLowerCase();
// The one rule the runtime can refuse on, so the example cannot document a colour the
// application would reject. Colours whose label is derived need no check: the better of
// white and black is never worse than 4.58:1, so every one of them has a compliant label.
if (requiresWhite.has(name) && contrastWithWhite(hex) < floor) {
problems.push(
`branding.tokens.${mode}.${name} is ${hex}, scoring ` +
`${contrastWithWhite(hex).toFixed(2)}:1 against white — below the ${floor}:1 floor. ` +
'This fill carries white text that no variable can change.',
);
}
}
}
const where = relative(ROOT, EXAMPLE);
if (problems.length > 0) {
console.error(`✗ The reference downstream config no longer resolves against this tree.\n`);
console.error(` ${where}\n`);
for (const problem of problems) console.error(` - ${problem}`);
console.error(
'\n A real deployment writing this file would hit the same failure on upgrade. Either the\n' +
' change that removed the id or token needs a deprecation, or the example needs updating\n' +
' to match a deliberate rename. See DOCS/CUSTOMIZATION.md.\n',
);
process.exit(1);
}
const counts = [
`${(nav.hidden ?? []).length} hidden`,
`${Object.keys(nav.overrides ?? {}).length} overridden`,
`${addedIds.length} added`,
`${Object.values(tokenSets).reduce((n, set) => n + Object.keys(set ?? {}).length, 0)} tokens`,
];
console.log(`✓ Reference downstream resolves: ${counts.join(', ')}.`);