Customising¶
Run panos-response-pages init first, then edit config/_defaults.json in the
copied tree, or add config/<customer>.json with only the keys
that differ — it is deep-merged over the defaults. Keys prefixed _ are inline
documentation and are ignored by the build.
| Key | Notes |
|---|---|
company |
Brand row, and the credential pages' "will never ask for your password" line |
supportEmail |
Target of every mailto:. Mutually exclusive with supportUrl |
supportUrl |
Absolute https:// ticket-system link, used instead of mailto: |
supportLabel |
What that link is called. supportUrl mode only; defaults to IT support |
logoSvg |
Inline SVG, ≤2 KB optimised. A traced-path export can be 40 KB and will silently break the page. Use currentColor so it inherits the theme. |
continueGrantText |
Must match your URL Admin Override timeout |
palette |
Which palette the preview gallery opens on: cyber-orange, strata-yellow or prisma-blue. Every style is built in every palette regardless; override per build with --palette. Setting it here also outranks a style that pins its own |
categories |
category → {tone, gloss}; tone is calm, warn or critical. An empty gloss means "no tailored copy" and falls back to defaultGloss/riskGloss — that is how a category earns a tone without paying for a sentence |
defaultGloss |
Used for any category not in the map — keep it true of every category |
riskGloss |
The same, for a warn or critical category. Separate because a banner reading "Security risk" over "restricted by company policy" contradicts itself |
redirect |
Opt-in handoff to a sanctioned app on the URL block page. Off by default — see below |
baseLanguage |
The language written into the markup as real text — see Languages |
languages |
Which of the thirteen shipped languages are compiled into the page. ["en"] is byte-identical to a build from before the feature existed |
translations |
Your own copy, per language. The strings files translate what this project ships; this translates what you changed |
Each page declares its own <!--@MARK--> — an inline SVG shown as a large
indicator beside the heading, tinted by severity. marks.warning in config is a
separate icon used by the warning callouts.
The category map is applied client-side, by reading the substituted
<category/> value from the DOM. PAN-OS exposes no severity variable and serves
one page per type, so per-category messaging cannot happen server-side.
The two credential pages set <!--@COPY_LOCK-->1<!--/@COPY_LOCK-->, which pins
their tone and gloss to what the template declares. A phishing interstitial must
not be repainted calm because of how its category happens to map.
Why the map is not all 90 categories¶
The Category row shows a friendly label — online-storage-and-backup
renders as "Online Storage and Backup". It is derived from the slug in the
browser rather than mapped, so all 90 PAN-OS categories get one, as will any
category Palo Alto adds after this build. An explicit label for each is ~3.3 KB
of JSON against ~0.2 KB of code, and the pages have no room for the difference.
The same arithmetic is why categories lists only the categories where the
default would be wrong. A category absent from the map renders calm with
defaultGloss, which is already the right answer for most of them — writing all
90 out with that same sentence adds ~5.6 KB and breaches the byte ceiling
without changing a single page. Entries are worth their bytes only for a
tailored gloss, or for a tone the default would get wrong; the latter cost
nothing but the tone, by leaving gloss empty.
Sending users to a ticket system¶
By default every "Report to IT" action opens the user's mail client with the incident already described — the user, the blocked address, the category and a prompt, folded into the mail body by a small script on the page.
A customer whose front door is a ticket system sets supportUrl instead:
{
"company": "Example Corp",
"supportEmail": "",
"supportUrl": "https://example.service-now.com/sp?id=sc_cat_item&sys_id=...",
"supportLabel": "the Service Desk"
}
supportLabel is optional and names the link. It is what a user reads where a
mailto: page would have printed the address — on the safe-search page and on
every portal page. Leave it out and the pages say "IT support". It has no effect
in supportEmail mode, where the address is its own label.
The blank supportEmail line is required, not decoration. Your customer file
is merged over _defaults.json, which ships a supportEmail; adding supportUrl
alone leaves both set and the build stops. Blanking is also the better habit than
deleting, because the next reader can see what the alternative was.
The URL must be absolute https://. A response page is served as the blocked
site, so a relative path resolves against whatever host the user was refused, and
an http:// link on a page whose whole job is to be trusted is not one.
The build also rejects a supportUrl that would break the page rather than
just look wrong: one with no host (https:// alone), one containing a quote,
an angle bracket, whitespace or a control character (it lands unescaped inside
href="{{CONTACT_HREF}}", so any of those breaks out of the attribute), and a
supportLabel containing < or > (it is printed as the link text). A query
string is fine — https://x.example.com/new?cat=1&sev=2 passes as written.
What you give up¶
The ticket link carries no context. A mailto: can pre-fill a subject and a body;
an <a href> cannot, so the user arrives at a blank ticket form and describes the
problem themselves.
The page still carries the context, though. Every contact link declares the incident metadata as attributes:
<a id="rep" data-subject="Blocked site report"
data-intro="Please review this block."
data-prompt="Why I need access:"
href="https://tickets.example.com/new">Report to IT</a>
Those three attributes are the seam for ticket-system support: a ServiceNow or
Jira Service Management adapter reads them and builds a pre-filled URL —
short_description from data-subject, description from data-intro plus the
page's fact table. That adapter does not exist yet; the attributes are already
there so that adding it does not mean editing all eleven page templates again.
Also affected¶
supportUrl applies to the GlobalProtect portal as well: the "Need help?" note on
every portal page, and the three logout messages that name a contact. The "Need
help?" note is a link, so it prints the label. The logout messages are not —
PAN-OS fills them in with .text(), so markup would render as literal
characters — and print the label and the URL as plain prose instead, e.g.
"Contact the Service Desk at https://tickets.example.com/new", so the address is
still something the user can actually find and use.
Redirecting to a sanctioned app¶
When a blocked category has a company-sanctioned equivalent, the URL block
page can name it and hand the user over after a countdown. It is off unless you
both set enabled and map at least one category — with either unset, not one
byte of it reaches any page.
"redirect": {
"enabled": true,
"seconds": 10,
"message": "Taking you to {app} — the approved alternative for this.",
"categories": {
"online-storage-and-backup": {
"app": "Company Drive",
"url": "https://drive.example.com/"
},
"web-based-email": {
"app": "Company Mail",
"url": "https://mail.example.com/",
"seconds": 5,
"message": "Work mail lives on {app}. Taking you there."
}
}
}
| Key | Notes |
|---|---|
enabled |
The toggle. A toggle with an empty categories does nothing |
seconds |
Default countdown, 1–60. Override per category |
message |
Default notice text. {app} is replaced with that category's app |
categories |
category → {app, url}, plus optional seconds and message |
Allow the target in policy first. If the sanctioned app is itself matched by the policy that produced the block, the user is sent to a page that blocks them.
The page will not loop on that: a response page is served as the blocked site, so it can see that the host it is being blocked on is one of your sanctioned apps, and it will not hop again. Because a hop only ever targets something in this table, every cycle passes through one of those hosts — so one wrong entry costs the user one wasted redirect, not an unbreakable loop. What no page can do is make the target reachable. That is policy's job.
Three rules the build enforces, because each fails in a way you would not see:
- Only a
calmcategory may redirect. The category must also appear incategoriesabove, and awarnorcriticaltone is refused. Nobody gets forwarded off a malware or phishing block, whatever the config says. The browser re-checks the tone the category map resolved before arming. urlmust be an absolutehttps://URL. It is read from your config and never from<url/>— that value is chosen by whoever the user was trying to reach, and a redirect built from it would make the firewall an open redirector.secondsmust be a whole number, 1–60.
It applies to the URL block page only. No other response page has a <category/>
token to key on, and the two coach pages already carry a Continue action that a
countdown would race.
The notice takes its colours from the shell, so every style renders it without opting in. It costs roughly 3.3 KB on the URL block page — check the size column in the build report if you are near the ceiling.
Cancelling — the Stay button or Esc — stops the countdown for that page
view only. The countdown also pauses while the tab is in the background, so a
tab left open behind others does not navigate itself.
Seeing it before you switch it on¶
The preview gallery grows a Redirect control whenever url-block-page is the
selected page. On renders the handoff; Off is the page as it is today.
It ignores redirect.enabled on purpose — the point is to evaluate the handoff
before committing to it, and enabled is false on every config until someone
opts in. What ships is still governed entirely by your config; only the gallery
looks past the flag.
Two things about the demo frame differ from what the firewall serves, both deliberate:
- The countdown restarts instead of handing over. The frame is a
srcdociframe onfile://, so navigating it would leave the gallery and need the network. The served page hands over exactly once. Everything else — Stay,Esc, the background-tab pause, the loop guard — is the script that ships. - The category is not the usual sample.
<category/>previews ascommand-and-control, which iscritical, and the page refuses to forward anyone off a security block. The demo stands in the first category you mapped, so the tone and gloss are the ones a user would really see. If you have mapped nothing yet it falls back toonline-storage-and-backup→ Company Drive, the worked example above.
The same page is written to preview/<style>/url-block-page-redirect.html. It is
preview-only and is never written under deploy/.
Languages¶
One imported page serves every language. PAN-OS gives a vsys exactly one page per
type, so a firewall with German and English speakers behind it cannot import two —
the choice has to happen in the browser. Every language you configure is compiled
into the page, and the browser picks one from navigator.languages at load time.
"baseLanguage": "en",
"languages": ["en", "de"]
| Key | Notes |
|---|---|
baseLanguage |
The language written into the markup as real text. It is what a browser with JavaScript disabled shows, and what any browser that matches nothing falls back to. Must appear in languages |
languages |
Every language compiled into the page, base included. Two-letter codes only; each needs a strings/<code>.json |
languages: ["en"] produces byte-identical output to a build from before this
feature existed. Not approximately — the test suite compares the bytes. A
single-language build emits no dictionary and no selector, and keeps the timestamp
script's previous form down to its variable name. That is what makes the feature
free for everyone who does not want it, and the assertion is how it stays free.
The build refuses, as a BuildError before any page is written: a baseLanguage
that is not in languages; a configured language with no strings file; a code that
is not two lowercase letters; an empty languages; and a translations block for a
language languages does not list — that last one is copy you wrote that no user
would ever read.
language 'en' is configured but en.json is missingon a tree you did not touch means the data directory predates this release:languagesdefaults to["en"], so a directoryinit-ed beforestrings/existed fails every build. Refresh it withpanos-response-pages init --force, backing up yourconfig/first. The message says so too.
Two-letter primary subtags only. The browser's tag is reduced to its primary
subtag before the lookup, so de matches de, de-AT, de-CH and de-DE.
Writing de-AT in languages is refused rather than quietly truncated: truncating
it would send the build looking for a file you did not write, and you would find out
from the missing translation rather than from the config.
What ships, and what you can compile¶
Thirteen languages ship as strings/<code>.json: English (en), German
(de), Spanish (es), Italian (it), French (fr), Dutch
(nl), Danish (da), Swedish (sv), Japanese (ja), Chinese,
Simplified (zh), Vietnamese (vi), Russian (ru) and Ukrainian
(uk).
Shipping thirteen files is not the same as compiling thirteen into a page, and
you cannot. A page has a byte ceiling and the GlobalProtect portal import is
refused above 16,170 B, so one build carries English plus three to five
others, depending which — see how many fit. That is
a property to plan around rather than a limitation to work around: no firewall
needs thirteen. A vsys in Munich takes ["en","de"], one in Osaka ["en","ja"].
languages is how you choose, and the files you do not list cost nothing.
Everything beyond English and German is model-drafted, and no native speaker has reviewed it. The eleven languages added after German were translated from
en.jsonby a model. They are complete, they pass every guard this project has, and each was rendered and read — but "passes the guards" is not "reads like something your legal department signed off". A response page is read by someone who has just been interrupted and is deciding whether to trust the page; wording that is merely correct is not enough for that. Have a speaker read the pages you intend to serve, before you serve them.Review for what a guard cannot check: the register, anything that reads as a literal translation rather than as native copy, wording that strays into the copy rules, and every word long enough to threaten a layout.
Translating your own copy¶
The strings files translate what this project ships. They cannot translate what
you wrote — a German defaultGloss is worthless to a customer who replaced the
English sentence it translates. Your translations therefore live in your own config
file, beside the English they translate:
{
"company": "Example Corp",
"continueGrantText": "30 minutes",
"defaultGloss": "Blocked under Example Corp policy.",
"languages": ["en", "de"],
"translations": {
"de": {
"continueGrantText": "30 Minuten",
"defaultGloss": "Gemäß Richtlinie von Example Corp gesperrt.",
"supportLabel": "der IT-Servicedesk",
"redirect": {
"message": "Weiterleitung zu {app} — die freigegebene Alternative.",
"categories": {
"web-based-email": "Dienstliche Post liegt auf {app}. Wir leiten Sie weiter."
}
}
}
}
}
Eligible keys: defaultGloss, riskGloss, continueGrantText, supportLabel, the
portal's logoutMessages, and the nested redirect block — its default message
and its per-category sentences.
Precedence mirrors config-over-defaults: your block beats the shipped strings file, and a key you leave out falls back to that language's shipped translation, not to the base language. The distinction is the whole point. Falling back to English would put one English sentence on an otherwise German page, which is exactly the half-translated result the build refuses everywhere else — and it would do it silently, because the page still builds and still validates.
redirect is the one block that merges a level down rather than wholesale.
Translating message and leaving categories alone keeps the shipped category
sentences; the alternative would make translating half the block discard the other
half without saying so.
Per-language category glosses are not here. They live in the optional
categories block of the strings file — see below — because they are this project's
map of PAN-OS categories, not your copy.
The redirect notice is translated in two halves. Its furniture — the Go now and Stay buttons, the line that replaces the sentence when you stay, and the two sentences a screen reader is read — is copy this project ships, so it lives in
shared.redirectin every strings file and is already German. Only the sentence is routed throughtranslations, because it names your sanctioned app in wording you may well have rewritten. Left untranslated it is the one English sentence on an otherwise German page, so write it when you switch the redirect on — and note that translatingcategorieswhile leavingmessagebehind is refused rather than falling back to English:messageis the fallback for every category, in each language exactly as in the base one.
Adding a language¶
- Copy
strings/en.jsontostrings/<code>.jsonin yourinit-ed data tree. - Translate every value. Leave every key exactly where it is.
- Set
langto the code, andnameto the language's English name —"French", not"Français". It is the only value in the file that is not translated: it labels the language in the preview gallery's dropdown, for a reviewer who does not read it. - Add the code to
languages.
Key parity is exact, in both directions. A missing key fails the build naming every path that is missing; an extra key fails too, because it is a typo or a stale entry and either way it is a string no page will ever read. Both are invisible in the output, which is why neither is a warning:
BuildError: de.json is out of step with en.json -- missing 4 key(s):
pages.ssl-cert-status-page.headline
pages.ssl-cert-status-page.gloss
pages.ssl-cert-status-page.facts[3]
pages.ssl-cert-status-page.report.subject
Lists are indexed rather than counted, so a facts array one entry short names the
position instead of reporting a length mismatch you then have to find by eye.
Every configured language must have a file, and there is no runtime fallback. No partially translated page, no missing key quietly resolving to English. The alternative is a line in a build log, which gets scrolled past, and the page ships.
A known cost of that, accepted rather than overlooked: adding a twelfth page type leaves the build red until every language file has an entry for it, even for someone who does not speak the language.
The one block a language file may omit is categories, the per-language category
glosses. Absent — the default — a non-base language shows the translated
defaultGloss/riskGloss for that category's tone, and the language costs about
1,800 B less on the two pages that carry the category map. The tone map itself is
never translated and never duplicated: severity, colour and the severity pill vary
per category identically in every language, and only the sentence changes. Category
labels are never translated either — they are title-cased from the PAN-OS slug, and
a user reading one back to IT should be reading what PAN-OS calls it.
Seeing a language before you switch it on¶
The preview gallery carries a Language dropdown listing every
strings/<code>.json in your data tree by its friendly name — English,
German, Chinese (Simplified). Picking one re-renders the frames in it.
It ignores languages on purpose, for the same reason the Redirect control
ignores redirect.enabled: the shipped default is languages: ["en"], so a
config-driven dropdown would be empty and the twelve languages that ship in the
tree unreachable. The gallery is where you decide which languages to compile,
so it has to show you the ones you have not compiled. What a firewall serves is
still governed entirely by languages; only the gallery looks past it. The extra
dictionaries exist in out/preview/ alone and cannot reach out/deploy/ — the
build refuses them there.
Selecting a language fetches it. Each non-base language is written to its own
preview/lang-<code>.js sidecar and loaded on demand, the same shape and the same
loader the palette blobs already use; the base language has no sidecar at all,
because it is the text the frames are already served in. So index.html does not
grow with the language count — it is about 1.83 MB whether two languages ship or
thirteen. Inlining all thirteen was the original design and it broke: the gallery
reached 2.88 MB against a 2.5 MB budget somewhere around the tenth language, and
a document that size is one nobody waits for. A language you never select costs a
file on disk and nothing else.
Two things about a swapped frame differ from what the firewall serves. The
timestamp keeps the format the frame loaded with, because the page has already
formatted it; and a style that declares "i18n": false compiles no languages at
all, so selecting it takes the control away rather than offering a choice its
pages cannot answer.
The control also overrides what your browser would have negotiated, which is the
point of it — navigator.languages decides what a real user gets, and this is
how you look at the other ones. A language whose file is out of step with the
base language's key set is left out of the list rather than offered and broken.
What a translator must not change¶
None of these is obvious from reading the file, and all of them fail quietly.
- A split string is the fragments either side of an element, and the element stays
between them.
shared.contactAltis["Or email ", " with the details above."]with the address in the gap;url-coach-text'sextrais three fragments where the middle one is the emphasised phrase inside a<strong>. The runtime swaps text nodes by position and never touches the element. A language whose word order needs the link or the emphasis first or last cannot be expressed in this shape — German does not; a language that does would need its own exception rather than a creative translation. - No fragment may be empty.
""renders no text node at all, the sentence collapses from three child nodes to two, and the runtime's shape check declines to swap it — leaving one sentence in the base language on an otherwise translated page, with a clean build behind it. Every other check passes it: the key exists, and the array is the right length. The build therefore refuses any empty string outright, naming the language and every path." "is fine, and several fragments legitimately end in a space. The single documented exception isshared.severity.calm, empty because a calm page carries a pill with no words in it. - No
<or>in any string. The build refuses both, in a strings file and in atranslationsblock, naming the language and every path. In the base language a<strong>reaches the markup through substitution and renders; in every other language the dictionary is handed totextContent, so the reader sees the literal characters<strong>— the same class of defect as an unresolved placeholder. A PAN-OS token is worse again.<user/>in a German gloss builds clean and validates clean (the token is legal on that page), and then the firewall expands it at serve time — inside a JS string literal. A username of the shapeACME\ukaiserreads as an invalid\uescape and the entire page script dies: no language swap, no category label, no timestamp, no report mail. On the portal a raw<is refused for a third reason —<pan_form/>silently stops being substituted and the login form disappears. - The same rule covers the copy in your config, and it does not wait for a
second language:
defaultGloss,riskGloss, everycategories.<name>.gloss,redirect.messageand eachredirect.categories.<name>.messageandappare compiled into the page script of every build, single-language ones included. A<user/>in any of them kills that script exactly as one in a strings file does, so the build refuses it and names the config path. These values reach the page throughtextContentand are never markup, so there is no tag to preserve — remove it and say the same thing in words. Nothing else in the config is checked this way:logoSvg,marks.*and the portal logos are SVG on purpose. {{COMPANY}},{{SUPPORT_EMAIL}}and{{CONTINUE_GRANT}}must survive verbatim. They are resolved per language at build time, so{{CONTINUE_GRANT}}inside a German sentence resolves to the GermancontinueGrantTextrather than to the English duration. Mangle one and the build fails naming the page.{app}and{n}in the redirect notice are a different syntax, and nothing checks them. They are single-braced because they are substituted in the browser by the redirect script, not by the build's{{...}}pass — which means the build's unresolved-placeholder check does not look at them.{{COMPANY}}mistyped fails the build;{App},{app }or{Anwendung}does not. The notice then simply names no application — on the one page whose job is to name one — or announces no countdown. Copy them character for character.{n}appears inshared.redirect.announce, which is a sentence rather than a concatenation precisely so a translation can put the number where its grammar wants it.
Styles that opt out: i18n: false¶
{ "name": "nyan", "shell": "nyan", "palette": "nyan", "i18n": false }
A theme may decline the extra languages. nyan does. Its worst page is 15,108 B and
has 892 B of headroom — less than a single extra language — because the star field
and the sprite artwork are half the file. It is a novelty style, and capping the
whole design around it would be the tail wagging the dog.
Three things to know about the flag:
- It is theme-level, not per page. It covers that theme's eleven block pages and both of its GlobalProtect portal imports. There is no way to keep the languages on a theme's portal while dropping them from its block pages.
- The pages still build. An opted-out style renders
baseLanguageas real text, builds every page and is still measured against the ceiling. It simply carries no dictionary and no selector. - It is reported, never silent. Shipping one language where you configured two is a real reduction in what your users get, so the build table says so on that style's rows:
nyan prisma-blue url-block-page 15108 ok en (base only -- i18n:false)
glass prisma-blue url-block-page 12811 ok en,de
Every other style that overflows fails the build. Automatic language-dropping was considered and rejected: a customer who configured French and silently gets it on four styles out of six is precisely the invisible failure this project exists to prevent.
What a language costs¶
Measured from real builds, not estimated. An earlier estimate put the one-off runtime at 240 B and was wrong by a factor of five — it grew while absorbing the page-shape fixes the feature turned out to need.
A language is charged in two parts. The runtime — the selector loop, the swap and the shape checks — is 1,197 B per page and is paid once, by whichever language you add first. Every language after that costs only its dictionary:
| Language | B/page | Language | B/page |
|---|---|---|---|
de German |
641 | sv Swedish |
745 |
zh Chinese (Simplified) |
651 | vi Vietnamese |
847 |
es / it / fr |
670–730 | ja Japanese |
1,025 |
da Danish |
718 | uk Ukrainian |
1,136 |
nl Dutch |
736 | ru Russian |
1,185 |
Measured as the marginal cost of adding that language to an existing two-language
build, so the runtime is already paid and this is the dictionary alone. German's
641 B was measured earlier, against a ["en"] build, and is the number the
×1.206 expansion figure elsewhere in this page comes from.
Only 6–9% of a built page is language-dependent. The rest is CSS, the SVG mark and the emitted script, none of which changes with language — which is why a language costs far less than the intuition of "another copy of the page".
The spread is 1.8×, so an average is the wrong number to plan with. Russian costs nearly two Chinese dictionaries. Budget the languages you are actually shipping, not "n × the typical language".
What predicts the cost is characters, not bytes per character¶
The intuition — Cyrillic is two bytes a character, CJK is three, so CJK must be
the expensive one — is backwards, and this table is what disproves it. Chinese
is the cheapest language in the project, because it needs about 0.3 characters
for every English character: three bytes each, but a third as many of them.
Japanese needs about 0.55 and is dearer than Vietnamese, because katakana
loanwords are a net loss — アプリケーション is 24 B against Application's 11.
Cyrillic pays two bytes a character and needs roughly as many characters as
English, which is why ru and uk sit at the top.
So the question to ask of a language you are considering is not what script it uses. It is how many characters it needs to say the same thing.
How many fit in one build¶
The GlobalProtect portal is the binding constraint — always, and not because it is merely tighter. It is the harder failure of the two. PAN-OS refuses a portal import above 16,170 B outright, at import time, with an error you cannot miss. An oversize block page imports clean, commits clean, and is then silently never displayed: the user gets the PAN-OS default and nobody is told.
Measured with real translations, on the worst import (beacon/login, 12,119 B
in English alone):
| Mix | Fits | Breaks |
|---|---|---|
Cheapest first — en zh da nl sv es |
6 languages | the 7th is refused |
Dearest first — en ru uk ja |
4 languages | the 5th is refused |
So: English plus three to five others, depending which. Pick from the cost table above; the difference between the two rows is entirely which languages you chose, not how the build behaves.
Block pages have more room than that, but planning against them is planning
against the wrong number — a set that fits every block page and not the portal
leaves you with a firewall you cannot finish configuring. Hold the 16,000 B warn
line rather than the 17,999 B serving ceiling on those pages: the 1,999 B gap
exists because <url/> expands at serve time, and a long blocked URL grows the
page after the byte count was taken.
You do not have to find the edge by trial: the build reports both families
against their own ceilings, and size is the one failure that names the
languages it was built with, because it is the one failure where "what could
come out of this file" is the next question. It also names the recovery —
dropping the optional per-language categories block first. The point of
failing the build is that it is the last place this is visible: past it, an
oversize block page is a page PAN-OS accepts and never shows.
German plus an enabled redirect crosses the warn line¶
On beacon, glass and mesh, url-block-page with German and the redirect
switched on lands over 16,000 B — by 202 B, 494 B and 84 B respectively. Nothing
comes near the 17,999 B hard ceiling, and the build warns rather than refusing.
That is a deliberate choice, and both alternatives are worse:
- Refusing would stop the build for an opt-in configuration because of a property of a style you may not deploy. The redirect costs roughly 3.3 KB on one page; three of six styles have room for it in two languages and three do not.
- Making the redirect language-aware — dropping the notice when a second language is configured — would make it vanish from a page you configured it on, with no error anywhere. That is the failure this project exists to prevent, traded for 494 bytes.
If you run one of those three styles with German and the redirect, check the size
column and decide whether you are comfortable at ~16.2 KB with <url/> still to
expand. Dropping the per-language categories block, if you added one, buys back
about 1,800 B on that page.
Checking it in a browser¶
Chrome needs --accept-lang, not --lang:
open -na "Google Chrome" --args --accept-lang=uk,en --user-data-dir=/tmp/chrome-uk \
file:///path/to/out/preview/glass/url-block-page.html
--lang changes Chrome's interface language and does not move
navigator.languages, so the page renders in English and looks like a broken
feature rather than a wrong flag. This has already caught someone on this branch,
twice. The same trap applies to any check you make: the selector reads
navigator.languages, so that is the thing that has to change.
Keep ,en on the end. It makes the check honest — the base language is second in
the list, exactly as it is for a real user who prefers Ukrainian, and it exercises
the rule that the base language stops the search only when it is ranked above a
compiled language. A single-entry --accept-lang=uk tests a browser nobody has.
Known rough edges¶
- The copy-rule guard knows English and German phrases only.
validate.BANNED_COPYrefuses two classes of claim a response page cannot substantiate — that data was or was not transmitted, and that a policy applies to all users — and the audit does run over every language file. But it matches phrases, and the phrase list is English and German. For the other eleven languages nothing in the build enforces the rule; the translators applied it by judgement. The trap is not hypothetical: on a credential-block page the sentence a native writer reaches for first is "your password was not sent", and the page cannot know that. Extending the list to eleven languages was considered and rejected — eleven sets of banned phrases is a maintenance burden with a false-positive risk this project has already been bitten by once, with a deliberately wide German phrase. Each reviewer checklist lists the phrases its language would contribute if the list is ever extended, so the work is recorded rather than lost. zhserves Simplified to Traditional readers. Language keys are two lowercase letters, sozhmatcheszh-CN,zh-TWandzh-HKalike and a Hong Kong or Taiwan browser is handed Simplified. There is nozh-Hans/zh-Hantdistinction to make inside a two-letter key space — that needs a fallback chain and a script-subtag rule the selector does not have. The only mitigation available is honesty in the label: the file'snameis "Chinese (Simplified)", so the preview dropdown and any reviewer see what they are actually looking at. If your users are predominantly Traditional readers, translate a variant into your own tree rather than shippingzhto them.- A reordered
factsarray is not caught. The length is: every strings file'sfactsarray is counted against the<dt>rows in that page's template, per page and per language. The order is not, and cannot be — labels swap positionally, so a permuted array is a page that builds clean, validates clean, and labels the Time row "User". Only reading the rendered page finds it, which is why every language was rendered before it was committed and why the reviewer checklists ask for the fact rows specifically. supportUrlmode puts an English "at" inside German logout messages. The portal's logout messages are filled by PAN-OS with.text(), so they cannot carry a link and print the contact as prose instead —"the Service Desk at https://…". Thatatis assembled in code rather than taken from a strings file, so it stays English whatever language the page selected. Pre-existing, and only visible insupportUrlmode.- PAN-OS's own login form is translated by reaching into its DOM. The
<pan_form/>substitution delivers PAN-OS's Englishplaceholder="Username",placeholder="Password"andvalue="Log In", which would otherwise sit inside an otherwise-German page. They are swapped by id —#user,#passwd,#submit,#new_passwd,#confirm_new_passwd— and swapped twice, because PAN-OS re-applies its own placeholders fromloadPageonwindow.onload, after our script has run. This is a dependency on PAN-OS's markup and worth re-checking on a major upgrade. Every swap is guarded, so a release that renames an id degrades to PAN-OS's own English wording rather than breaking the page — the same degradation the download widget already accepts on#taGetSofewarePage.