Push the *actually* staged file, instead of the older version...

Signed-off-by: PanteraPolnocy <panterapolnocy@gmail.com>
This commit is contained in:
PanteraPolnocy
2026-06-14 18:13:41 +02:00
parent cc3de4fb49
commit 1bd27c0a1a
+433 -44
View File
@@ -13,7 +13,14 @@ OUT_FILE = REPO / "_audit_result.html"
TRANSLATABLE_ATTRS = frozenset({
"label", "tool_tip", "title", "text", "yestext", "notext", "canceltext",
"message", "help_text", "short_desc", "long_desc",
"message", "help_text", "short_desc", "long_desc", "value",
})
NUMERIC_VALUE_RE = re.compile(r"^-?\d+(\.\d+)?$")
PLACEHOLDER_TOKEN_RE = re.compile(r"\[[^\]]+\]|\$[A-Za-z_][A-Za-z0-9_]*")
STRING_BODY_TAGS = frozenset({
"global", "notification", "text", "string", "ignore", "button",
})
ONLY_PLACEHOLDERS_RE = re.compile(
@@ -93,12 +100,47 @@ class AttrIssue:
hint: str = ""
@dataclass
class PlaceholderIssue:
key: str
en_value: str
locale_value: str
missing: list[str] = field(default_factory=list)
extra: list[str] = field(default_factory=list)
@dataclass
class ParseErrorRecord:
skin: str
locale: str
rel: str
message: str
@dataclass
class DuplicateNameRecord:
rel: str
name: str
count: int
@dataclass
class FileReport:
rel: str
missing: list[Entry] = field(default_factory=list)
redundant: list[Entry] = field(default_factory=list)
extra_attrs: list[AttrIssue] = field(default_factory=list)
unchanged: list[Entry] = field(default_factory=list)
placeholder_issues: list[PlaceholderIssue] = field(default_factory=list)
locale_only_elements: list[str] = field(default_factory=list)
@property
def has_issues(self) -> bool:
return bool(
self.missing or self.redundant or self.extra_attrs
or self.unchanged or self.placeholder_issues
or self.locale_only_elements,
)
@dataclass
@@ -122,6 +164,18 @@ class LocaleReport:
def total_extra_attrs(self) -> int:
return sum(len(r.extra_attrs) for r in self.file_reports)
@property
def total_unchanged(self) -> int:
return sum(len(r.unchanged) for r in self.file_reports)
@property
def total_placeholder_issues(self) -> int:
return sum(len(r.placeholder_issues) for r in self.file_reports)
@property
def total_locale_only_elements(self) -> int:
return sum(len(r.locale_only_elements) for r in self.file_reports)
@property
def files_with_missing(self) -> int:
return sum(1 for r in self.file_reports if r.missing)
@@ -137,7 +191,8 @@ class LocaleReport:
@property
def has_issues(self) -> bool:
return bool(
self.missing_files or self.redundant_files or self.file_reports,
self.missing_files or self.redundant_files
or any(r.has_issues for r in self.file_reports),
)
@@ -145,11 +200,24 @@ class LocaleReport:
class SkinReport:
skin: str
locale_reports: list[LocaleReport] = field(default_factory=list)
duplicate_names_by_file: dict[str, list[DuplicateNameRecord]] = field(
default_factory=dict,
)
@property
def en_count(self) -> int:
return self.locale_reports[0].en_count if self.locale_reports else 0
@property
def total_duplicate_names(self) -> int:
return sum(len(v) for v in self.duplicate_names_by_file.values())
@dataclass
class AuditReport:
skin_reports: list[SkinReport] = field(default_factory=list)
parse_errors: list[ParseErrorRecord] = field(default_factory=list)
def normalise_text(text: str) -> str:
if text is None:
@@ -157,6 +225,40 @@ def normalise_text(text: str) -> str:
return " ".join(text.split())
def is_numeric_value(value: str) -> bool:
v = normalise_text(value)
return bool(v) and bool(NUMERIC_VALUE_RE.match(v))
def tag_name(elem: ET.Element) -> str:
return elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
def tag_has_string_body(tag: str) -> bool:
return tag in STRING_BODY_TAGS or tag.endswith(".string")
def direct_element_text(elem: ET.Element) -> str:
return normalise_text(elem.text or "")
def string_content(elem: ET.Element) -> str:
body = direct_element_text(elem)
if body:
return body
return normalise_text(elem.attrib.get("value", ""))
def extract_placeholders(text: str) -> frozenset[str]:
return frozenset(PLACEHOLDER_TOKEN_RE.findall(text))
def is_translatable_attr_value(attr: str, value: str) -> bool:
if attr == "value" and is_numeric_value(value):
return False
return True
def is_trivial(value: str, key: str = "") -> bool:
v = normalise_text(value)
if not v:
@@ -219,9 +321,42 @@ def needs_translation_entry(value: str, key: str) -> bool:
name = key[1:].split("::", 1)[0]
if value == name:
return False
if key.endswith("::value") and is_numeric_value(value):
return False
return True
def find_duplicate_names(root: ET.Element) -> list[DuplicateNameRecord]:
counts: dict[str, int] = {}
for elem in root.iter():
name = elem.attrib.get("name", "").strip()
if name:
counts[name] = counts.get(name, 0) + 1
return [
DuplicateNameRecord(name=name, count=count, rel="")
for name, count in sorted(counts.items())
if count > 1
]
def element_has_translatable_content(
ekey: str,
attrs: dict[str, str],
entries: dict[str, Entry],
) -> bool:
prefix = f"{ekey}::"
for key, entry in entries.items():
if key.startswith(prefix) and needs_translation_entry(entry.value, key):
return True
for attr, value in attrs.items():
if not is_translatable_attr_value(attr, value):
continue
key = f"{ekey}::{attr}"
if needs_translation_entry(value, key):
return True
return False
def is_translate_disabled(elem: ET.Element) -> bool:
return elem.attrib.get("translate", "").strip().lower() in TRANSLATE_FALSE_VALUES
@@ -301,6 +436,8 @@ def looks_translation_attr(attr: str) -> bool:
def should_report_extra_attr(attr: str, value: str, en_attrs: dict[str, str]) -> bool:
if attr == "value" and is_numeric_value(value):
return False
if attr in ATTR_TYPO_HINTS:
return True
if attr in TRANSLATABLE_ATTRS:
@@ -390,34 +527,34 @@ def extract_entries(root: ET.Element, rel: str) -> dict[str, Entry]:
if rel == "strings.xml":
for child in root:
tag = child.tag.split("}")[-1]
if tag != "string":
if tag_name(child) != "string":
continue
if is_translate_disabled(child):
continue
name = child.attrib.get("name", "").strip()
if not name:
continue
value = normalise_text("".join(child.itertext()))
value = string_content(child)
key = f"string:{name}"
entries[key] = Entry(key, value, "#text")
return entries
def walk(elem: ET.Element) -> None:
tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
tag = tag_name(elem)
ident = element_identity(elem)
if not is_translate_disabled(elem):
for attr in TRANSLATABLE_ATTRS:
if attr in elem.attrib:
value = normalise_text(elem.attrib[attr])
key = entry_key(elem, attr)
entries[key] = Entry(key, value, attr)
if attr not in elem.attrib:
continue
value = normalise_text(elem.attrib[attr])
if not is_translatable_attr_value(attr, value):
continue
key = entry_key(elem, attr)
entries[key] = Entry(key, value, attr)
body = normalise_text("".join(elem.itertext()))
if ident and body and tag in {
"global", "notification", "text", "string", "ignore", "button",
}:
body = direct_element_text(elem)
if ident and body and tag_has_string_body(tag):
key = entry_key(elem, "#text")
if key not in entries:
entries[key] = Entry(key, body, "#text")
@@ -429,11 +566,22 @@ def extract_entries(root: ET.Element, rel: str) -> dict[str, Entry]:
return entries
def parse_xml(path: Path) -> ET.Element | None:
def parse_xml(
path: Path,
parse_errors: list[ParseErrorRecord] | None = None,
skin: str = "",
locale: str = "",
rel: str = "",
) -> ET.Element | None:
try:
return ET.parse(path).getroot()
except ET.ParseError as exc:
message = str(exc)
print(f"Parse error in {path}: {exc}")
if parse_errors is not None:
parse_errors.append(ParseErrorRecord(
skin=skin, locale=locale, rel=rel or path.name, message=message,
))
return None
@@ -448,9 +596,18 @@ def collect_files(base: Path) -> dict[str, Path]:
return files
def compare_file(rel: str, en_path: Path, locale_path: Path | None) -> FileReport:
def compare_file(
rel: str,
en_path: Path,
locale_path: Path | None,
parse_errors: list[ParseErrorRecord] | None = None,
skin: str = "",
locale: str = "",
) -> FileReport:
report = FileReport(rel=rel)
en_root = parse_xml(en_path)
en_root = parse_xml(
en_path, parse_errors, skin=skin, locale="en", rel=rel,
)
if en_root is None:
return report
@@ -466,19 +623,40 @@ def compare_file(rel: str, en_path: Path, locale_path: Path | None) -> FileRepor
report.missing = list(en_entries.values())
return report
locale_root = parse_xml(locale_path)
locale_root = parse_xml(
locale_path, parse_errors, skin=skin, locale=locale, rel=rel,
)
if locale_root is None:
report.missing = list(en_entries.values())
return report
locale_all = extract_entries(locale_root, rel)
locale_entries = {
k: v for k, v in locale_all.items() if not is_trivial(v.value, k)
k: v for k, v in locale_all.items()
if needs_translation_entry(v.value, k)
}
for key, entry in en_entries.items():
if key not in locale_all:
report.missing.append(entry)
continue
loc_entry = locale_all[key]
if loc_entry.value == entry.value:
report.unchanged.append(entry)
continue
en_ph = extract_placeholders(entry.value)
if en_ph:
loc_ph = extract_placeholders(loc_entry.value)
missing_ph = sorted(en_ph - loc_ph)
extra_ph = sorted(loc_ph - en_ph)
if missing_ph or extra_ph:
report.placeholder_issues.append(PlaceholderIssue(
key=key,
en_value=entry.value,
locale_value=loc_entry.value,
missing=missing_ph,
extra=extra_ph,
))
for key, entry in locale_entries.items():
if key not in en_all:
@@ -496,6 +674,16 @@ def compare_file(rel: str, en_path: Path, locale_path: Path | None) -> FileRepor
skip_elements=en_no_translate,
)
for ekey in sorted(locale_elements):
if ekey in en_elements:
continue
if ekey in en_no_translate:
continue
if element_has_translatable_content(
ekey, locale_elements[ekey], locale_all,
):
report.locale_only_elements.append(ekey)
return report
@@ -519,7 +707,11 @@ def discover_locales(skin: str) -> list[str]:
)
def audit_locale(skin: str, locale: str) -> LocaleReport:
def audit_locale(
skin: str,
locale: str,
parse_errors: list[ParseErrorRecord],
) -> LocaleReport:
en_dir = SKINS_ROOT / skin / "xui" / "en"
locale_dir = SKINS_ROOT / skin / "xui" / locale
@@ -538,30 +730,63 @@ def audit_locale(skin: str, locale: str) -> LocaleReport:
)
for rel in sorted(en_rels & locale_rels):
file_report = compare_file(rel, en_files[rel], locale_files.get(rel))
if file_report.missing or file_report.redundant or file_report.extra_attrs:
file_report = compare_file(
rel, en_files[rel], locale_files.get(rel),
parse_errors=parse_errors, skin=skin, locale=locale,
)
if file_report.has_issues:
report.file_reports.append(file_report)
for rel in report.missing_files:
en_root = parse_xml(en_files[rel])
en_root = parse_xml(
en_files[rel], parse_errors, skin=skin, locale="en", rel=rel,
)
if en_root is None:
continue
en_all = extract_entries(en_root, rel)
entries = [
e for e in en_all.values() if needs_translation_entry(e.value, e.key)
en_no_translate = collect_no_translate_targets(en_root, rel)
missing = [
e for e in en_all.values()
if needs_translation_entry(e.value, e.key)
and not entry_is_non_translatable(e.key, en_no_translate)
]
if entries:
report.file_reports.append(FileReport(rel=rel, missing=entries))
if missing:
report.file_reports.append(FileReport(rel=rel, missing=missing))
report.file_reports.sort(key=lambda r: r.rel)
return report
def audit_skin(skin: str) -> SkinReport:
def scan_en_duplicate_names(
skin: str,
parse_errors: list[ParseErrorRecord],
) -> dict[str, list[DuplicateNameRecord]]:
en_dir = SKINS_ROOT / skin / "xui" / "en"
by_file: dict[str, list[DuplicateNameRecord]] = {}
for rel, path in collect_files(en_dir).items():
root = parse_xml(path, parse_errors, skin=skin, locale="en", rel=rel)
if root is None:
continue
dupes = find_duplicate_names(root)
if dupes:
by_file[rel] = [
DuplicateNameRecord(rel=rel, name=d.name, count=d.count)
for d in dupes
]
return by_file
def audit_skin(
skin: str,
parse_errors: list[ParseErrorRecord],
) -> SkinReport:
locales = discover_locales(skin)
return SkinReport(
skin=skin,
locale_reports=[audit_locale(skin, loc) for loc in locales],
locale_reports=[
audit_locale(skin, loc, parse_errors) for loc in locales
],
duplicate_names_by_file=scan_en_duplicate_names(skin, parse_errors),
)
@@ -583,6 +808,12 @@ def render_file_report(report: FileReport, locale: str) -> str:
parts.append(badge(len(report.redundant), "redundant"))
if report.extra_attrs:
parts.append(badge(len(report.extra_attrs), "extra-attr"))
if report.unchanged:
parts.append(badge(len(report.unchanged), "unchanged"))
if report.placeholder_issues:
parts.append(badge(len(report.placeholder_issues), "placeholder"))
if report.locale_only_elements:
parts.append(badge(len(report.locale_only_elements), "locale-only"))
parts.append("</h4><div class='file-body'>")
if report.missing:
@@ -632,6 +863,48 @@ def render_file_report(report: FileReport, locale: str) -> str:
)
parts.append("</table></div>")
if report.unchanged:
parts.append("<div class='unchanged'>")
parts.append(
f"<h5>Identical to EN in {html.escape(locale)} (likely untranslated)</h5>",
)
parts.append(
"<table class='entries'><tr><th>Key</th><th>Value</th></tr>",
)
for entry in sorted(report.unchanged, key=lambda e: e.key):
parts.append(
f"<tr><td><code>{html.escape(entry.key)}</code></td>"
f"<td>{html.escape(entry.value)}</td></tr>",
)
parts.append("</table></div>")
if report.placeholder_issues:
parts.append("<div class='placeholder'>")
parts.append(f"<h5>Placeholder mismatch in {html.escape(locale)}</h5>")
parts.append(
"<table class='entries'><tr><th>Key</th><th>EN value</th>"
f"<th>{html.escape(locale)} value</th><th>Missing</th><th>Extra</th></tr>",
)
for issue in sorted(report.placeholder_issues, key=lambda i: i.key):
parts.append(
f"<tr><td><code>{html.escape(issue.key)}</code></td>"
f"<td>{html.escape(issue.en_value)}</td>"
f"<td>{html.escape(issue.locale_value)}</td>"
f"<td>{html.escape(', '.join(issue.missing))}</td>"
f"<td>{html.escape(', '.join(issue.extra))}</td></tr>",
)
parts.append("</table></div>")
if report.locale_only_elements:
parts.append("<div class='locale-only'>")
parts.append(
f"<h5>Elements in {html.escape(locale)} not present in EN file</h5>",
)
parts.append("<ul class='files'>")
for ekey in report.locale_only_elements:
parts.append(f"<li><code>{html.escape(ekey)}</code></li>")
parts.append("</ul></div>")
parts.append("</div></div>")
return "\n".join(parts)
@@ -670,6 +943,21 @@ def render_locale_report(locale_report: LocaleReport) -> str:
f"<span class='stat'>bad attributes: "
f"{badge(locale_report.total_extra_attrs, 'extra-attr')}</span>",
)
if locale_report.total_unchanged:
parts.append(
f"<span class='stat'>unchanged: "
f"{badge(locale_report.total_unchanged, 'unchanged')}</span>",
)
if locale_report.total_placeholder_issues:
parts.append(
f"<span class='stat'>placeholders: "
f"{badge(locale_report.total_placeholder_issues, 'placeholder')}</span>",
)
if locale_report.total_locale_only_elements:
parts.append(
f"<span class='stat'>locale-only widgets: "
f"{badge(locale_report.total_locale_only_elements, 'locale-only')}</span>",
)
parts.append("</summary>")
parts.append("<div class='locale-body'>")
@@ -686,6 +974,9 @@ def render_locale_report(locale_report: LocaleReport) -> str:
("Total redundant entries", locale_report.total_redundant),
("Bad attributes (not on EN element)", locale_report.total_extra_attrs),
("Files with bad attributes", locale_report.files_with_extra_attrs),
("Unchanged (same as EN)", locale_report.total_unchanged),
("Placeholder mismatches", locale_report.total_placeholder_issues),
("Locale-only widgets", locale_report.total_locale_only_elements),
):
parts.append(
f"<tr><th>{html.escape(label)}</th><td>{value}</td></tr>",
@@ -744,6 +1035,27 @@ def render_skin_panel(skin_report: SkinReport, active: bool) -> str:
"<p class='note'>No non-EN locales found for this skin.</p>",
)
else:
if skin_report.duplicate_names_by_file:
total_dupes = skin_report.total_duplicate_names
parts.append(
f"<h4 class='subsection-heading'>Duplicate widget names in EN "
f"({total_dupes} across {len(skin_report.duplicate_names_by_file)} files)</h4>",
)
parts.append(
"<p class='note'>Multiple elements share the same <code>name</code> "
"in one file; string matching may be unreliable for these.</p>",
)
parts.append("<table class='entries'>")
parts.append("<tr><th>File</th><th>Widget name</th><th>Count</th></tr>")
for rel in sorted(skin_report.duplicate_names_by_file):
for dup in skin_report.duplicate_names_by_file[rel]:
parts.append(
f"<tr><td><code>{html.escape(rel)}</code></td>"
f"<td><code>{html.escape(dup.name)}</code></td>"
f"<td>{dup.count}</td></tr>",
)
parts.append("</table>")
for locale_report in skin_report.locale_reports:
parts.append(render_locale_report(locale_report))
@@ -792,6 +1104,9 @@ def render_matrix(skin_reports: list[SkinReport]) -> str:
extra = (
locale_report.total_redundant
+ locale_report.total_extra_attrs
+ locale_report.total_unchanged
+ locale_report.total_placeholder_issues
+ locale_report.total_locale_only_elements
)
cell = f"{missing}"
if extra:
@@ -803,7 +1118,47 @@ def render_matrix(skin_reports: list[SkinReport]) -> str:
return "\n".join(parts)
def render_html(skin_reports: list[SkinReport]) -> str:
def render_parse_errors(parse_errors: list[ParseErrorRecord]) -> str:
if not parse_errors:
return ""
parts = [
"<h2>XML parse errors</h2>",
"<p class='note'>These files could not be parsed and were skipped "
"for that locale comparison.</p>",
"<table class='entries'>",
"<tr><th>Skin</th><th>Locale</th><th>File</th><th>Error</th></tr>",
]
for err in parse_errors:
parts.append(
f"<tr><td><code>{html.escape(err.skin)}</code></td>"
f"<td>{html.escape(err.locale)}</td>"
f"<td><code>{html.escape(err.rel)}</code></td>"
f"<td>{html.escape(err.message)}</td></tr>",
)
parts.append("</table>")
return "\n".join(parts)
def locale_issue_total(locale_report: LocaleReport) -> int:
return (
locale_report.total_missing
+ locale_report.total_redundant
+ locale_report.total_extra_attrs
+ locale_report.total_unchanged
+ locale_report.total_placeholder_issues
+ locale_report.total_locale_only_elements
)
def skin_issue_total(skin_report: SkinReport) -> int:
return (
sum(locale_issue_total(lr) for lr in skin_report.locale_reports)
+ skin_report.total_duplicate_names
)
def render_html(audit: AuditReport) -> str:
skin_reports = audit.skin_reports
audit_date = date.today().isoformat()
total_missing = sum(
lr.total_missing for sr in skin_reports for lr in sr.locale_reports
@@ -814,6 +1169,16 @@ def render_html(skin_reports: list[SkinReport]) -> str:
total_extra_attrs = sum(
lr.total_extra_attrs for sr in skin_reports for lr in sr.locale_reports
)
total_unchanged = sum(
lr.total_unchanged for sr in skin_reports for lr in sr.locale_reports
)
total_placeholder = sum(
lr.total_placeholder_issues for sr in skin_reports for lr in sr.locale_reports
)
total_locale_only = sum(
lr.total_locale_only_elements for sr in skin_reports for lr in sr.locale_reports
)
total_duplicate_names = sum(sr.total_duplicate_names for sr in skin_reports)
locale_count = sum(len(sr.locale_reports) for sr in skin_reports)
skins_with_locales = sum(1 for sr in skin_reports if sr.locale_reports)
@@ -831,6 +1196,10 @@ def render_html(skin_reports: list[SkinReport]) -> str:
" --missing:#b91c1c;--missing-bg:#fef2f2;",
" --redundant:#b45309;--redundant-bg:#fffbeb;",
" --extra-attr:#7c3aed;--extra-attr-bg:#f5f3ff;",
" --unchanged:#0369a1;--unchanged-bg:#e0f2fe;",
" --placeholder:#c2410c;--placeholder-bg:#fff7ed;",
" --locale-only:#0f766e;--locale-only-bg:#ccfbf1;",
" --parse-error:#9f1239;--parse-error-bg:#fff1f2;",
" --ok:#15803d;--ok-bg:#f0fdf4;",
"}",
"*{box-sizing:border-box;}",
@@ -891,9 +1260,15 @@ def render_html(skin_reports: list[SkinReport]) -> str:
".badge-missing{background:var(--missing-bg);color:var(--missing);}",
".badge-redundant{background:var(--redundant-bg);color:var(--redundant);}",
".badge-extra-attr{background:var(--extra-attr-bg);color:var(--extra-attr);}",
".badge-unchanged{background:var(--unchanged-bg);color:var(--unchanged);}",
".badge-placeholder{background:var(--placeholder-bg);color:var(--placeholder);}",
".badge-locale-only{background:var(--locale-only-bg);color:var(--locale-only);}",
".missing h5{color:var(--missing);}",
".redundant h5{color:var(--redundant);}",
".extra-attr h5{color:var(--extra-attr);}",
".unchanged h5{color:var(--unchanged);}",
".placeholder h5{color:var(--placeholder);}",
".locale-only h5{color:var(--locale-only);}",
"table.entries{width:100%;border-collapse:collapse;font-size:.82rem;margin:8px 0;}",
"table.entries th,table.entries td{border:1px solid var(--border);"
"padding:4px 8px;vertical-align:top;}",
@@ -936,13 +1311,15 @@ def render_html(skin_reports: list[SkinReport]) -> str:
f"EN against every other locale present. Generated {audit_date}.</p>",
"</header>",
"<p class='note'>Entries are matched by widget <code>name</code> (viewer "
"layered-XML rules). Each locale element is also checked attribute-by-attribute "
"against its EN counterpart: attributes present in the translation but absent "
"on EN (e.g. <code>tooltip</code> instead of <code>tool_tip</code>) are flagged. "
"Elements with <code>translate=\"false\"</code> are skipped (direct content only, "
"not children). Omitted as trivial: integers, placeholder-only text, keyboard labels, slash "
"commands, font names, brand constants, and EN labels identical to the widget "
"name.</p>",
"layered-XML rules). Audits <code>value</code> attributes and "
"<code>panel.string</code> / <code>floater.string</code> bodies. Purely "
"numeric <code>value</code> attributes (sizes, combo indices, padding) are "
"ignored. Each locale element is also checked attribute-by-attribute against "
"its EN counterpart. Elements with <code>translate=\"false\"</code> are skipped "
"(direct content only, not children). Also flags unchanged EN copies, placeholder "
"token mismatches, locale-only widgets, duplicate widget names, and XML parse "
"errors.</p>",
render_parse_errors(audit.parse_errors),
"<div class='global-summary'>",
f"<div class='stat-card'><div class='value'>{len(skin_reports)}</div>"
"<div class='label'>Skins scanned</div></div>",
@@ -956,6 +1333,16 @@ def render_html(skin_reports: list[SkinReport]) -> str:
"<div class='label'>Total redundant strings</div></div>",
f"<div class='stat-card'><div class='value'>{total_extra_attrs}</div>"
"<div class='label'>Bad attributes (not on EN)</div></div>",
f"<div class='stat-card'><div class='value'>{total_unchanged}</div>"
"<div class='label'>Unchanged (same as EN)</div></div>",
f"<div class='stat-card'><div class='value'>{total_placeholder}</div>"
"<div class='label'>Placeholder mismatches</div></div>",
f"<div class='stat-card'><div class='value'>{total_locale_only}</div>"
"<div class='label'>Locale-only widgets</div></div>",
f"<div class='stat-card'><div class='value'>{total_duplicate_names}</div>"
"<div class='label'>Duplicate widget names</div></div>",
f"<div class='stat-card'><div class='value'>{len(audit.parse_errors)}</div>"
"<div class='label'>XML parse errors</div></div>",
"</div>",
render_matrix(skin_reports),
"<h2>By skin</h2>",
@@ -964,10 +1351,7 @@ def render_html(skin_reports: list[SkinReport]) -> str:
for index, skin_report in enumerate(skin_reports):
skin = skin_report.skin
issue_count = sum(
lr.total_missing + lr.total_redundant + lr.total_extra_attrs
for lr in skin_report.locale_reports
)
issue_count = skin_issue_total(skin_report)
active = " active" if index == 0 else ""
parts.append(
f"<button type='button' class='skin-tab{active}' data-skin='"
@@ -991,10 +1375,15 @@ def main() -> None:
print(f"No skins with xui/en found under {SKINS_ROOT}")
return
skin_reports = [audit_skin(skin) for skin in skins]
output = render_html(skin_reports)
parse_errors: list[ParseErrorRecord] = []
skin_reports = [audit_skin(skin, parse_errors) for skin in skins]
audit = AuditReport(skin_reports=skin_reports, parse_errors=parse_errors)
output = render_html(audit)
OUT_FILE.write_text(output, encoding="utf-8")
print(f"Wrote {OUT_FILE} ({len(skins)} skins)")
print(
f"Wrote {OUT_FILE} ({len(skins)} skins, "
f"{len(parse_errors)} parse errors)",
)
if __name__ == "__main__":