v0.34.9_css_refactor
This commit is contained in:
1 parent
2b7c67d755
commit
66ff9dfaa2
28 files changed
+11518
-13023
No files matched your search
@@ -10,6 +10,74 @@ External development and QA helpers live under `tools/`; external platform integ
|
||||
|
||||
Keep these paths stable because Rust source, `build.rs` and shell entry points reference them directly.
|
||||
|
||||
## Panel stylesheet sources
|
||||
|
||||
The dashboard stylesheet lives in `src/admin/web/css/`, with its complete cascade
|
||||
order declared in `manifest.json`. Edit those modules, not a generated bundle.
|
||||
`build.rs` invokes `build/panel_css.py` using Python 3.10+ (or
|
||||
`RUST_PROXY_PYTHON`) and embeds the compact `OUT_DIR/panel-style.css`. Login and
|
||||
the dashboard continue to load one fingerprinted stylesheet, using the existing
|
||||
panel compression and access rules; no npm install or runtime CSS imports are
|
||||
needed. The documentation page retains its separate `src/admin/web/docs.css`.
|
||||
|
||||
```bash
|
||||
python3 scripts/build/panel_css.py --check
|
||||
python3 tools/dev/test_panel_css.py
|
||||
python3 scripts/audit_css_usage.py --strict
|
||||
```
|
||||
|
||||
The first two commands use only the Python standard library. The optional
|
||||
selector/structure audit needs `tinycss2` and `cssselect2`; it checks all manifest
|
||||
modules together and reviews the documentation stylesheet separately. Use
|
||||
`--json PATH` for selector references and `--structure-json PATH` for repeated
|
||||
selectors, conditional blocks, properties and identical declaration blocks.
|
||||
Missing class tokens are review candidates: classes assembled by JavaScript
|
||||
must be checked before deleting their styles. Strict mode also rejects identical
|
||||
declaration bodies within a compatible selector family. It permits separate
|
||||
standard/WebKit/Gecko rules only when joining them would create an incompatible
|
||||
vendor selector list. Choose shared-rule placement by checking the cascade,
|
||||
not by mechanically moving every matching block to the beginning of a module.
|
||||
|
||||
Preflight rejects missing, repeated or unlisted CSS modules and stale monolithic
|
||||
`style.css` files. `./dev.sh repair` backs up only the exact original 0.34.9
|
||||
monolith; custom or unknown content requires manual review and is never moved
|
||||
or discarded automatically. Rebuild the Rust binary after changing CSS.
|
||||
|
||||
Keep `scripts/__init__.py` and `scripts/build/__init__.py` in the source package.
|
||||
They keep preflight, audit, tests and packaging imports local even when unrelated
|
||||
Python packages named `scripts` or `build` are installed. An older CSS-refactor
|
||||
archive without the build marker can fail
|
||||
with `ModuleNotFoundError: No module named 'build.panel_css'`; restore the marker
|
||||
from the complete source archive and run `bash dev.sh repair` again. Installing
|
||||
another Python package is not required.
|
||||
|
||||
### Missing generated CSS during Rust compilation
|
||||
|
||||
`panel-style.css` is generated inside Cargo's `OUT_DIR`. `bash dev.sh repair`
|
||||
validates the source layout and preserves known obsolete files; it does not
|
||||
generate Cargo outputs. Preflight also checks that the current `build.rs` calls
|
||||
the panel generator from `main` and reports a mixed source tree before Cargo.
|
||||
|
||||
After overlaying a source ZIP onto a previously built checkout, a compiler error
|
||||
for a missing `OUT_DIR/panel-style.css` can indicate an old cached build script
|
||||
or a `build.rs` from another source version. Restore the complete matching
|
||||
archive, including the root `build.rs`, then run from the project root:
|
||||
|
||||
```bash
|
||||
bash dev.sh repair
|
||||
touch build.rs
|
||||
bash dev.sh build
|
||||
```
|
||||
|
||||
The source ZIP uses reproducible timestamps. Cargo's
|
||||
[build-script change detection](https://doc.rust-lang.org/cargo/reference/build-scripts.html#change-detection)
|
||||
uses file modification times, so refreshing `build.rs` forces regeneration after
|
||||
an archive overlay. This works with the target directory selected by `dev.sh`.
|
||||
The generator now also registers its output as a watched Cargo dependency and
|
||||
recreates a removed bundle. An identical bundle keeps its modification time.
|
||||
The Rust build script rejects a missing, empty or non-file output immediately
|
||||
after the helper returns.
|
||||
|
||||
## Stream TCP/UDP test helpers
|
||||
|
||||
- `start_stream_backends.sh` — launches two TCP echo mocks (9101, 9102) and two UDP echo/probe mocks (9201, 9202), defaulting to loopback, and terminates all on Ctrl+C.
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Application-owned RustProxy scripts."""
|
||||
+122
-11
@@ -3,14 +3,14 @@
|
||||
|
||||
Requires: pip install tinycss2 cssselect2 (audit-only tooling, not runtime deps).
|
||||
Output is an inventory, NOT permission to remove dynamically generated styles.
|
||||
Selectors are considered dead only when a required class/ID token is absent
|
||||
from all application templates and scripts; :not() is not treated as a requirement.
|
||||
Absent class/ID tokens are candidates for review, because templates can assemble
|
||||
class names at runtime; :not() is not treated as a positive requirement.
|
||||
The Swagger UI bundle emits its own markup, so docs.css is reviewed separately.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -23,14 +23,106 @@ except ImportError as exc:
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / 'src/admin/web'
|
||||
STYLES = ('style.css', 'docs.css')
|
||||
SOURCE_SUFFIXES = {'.js', '.html', '.json', '.rs'}
|
||||
UNSUPPORTED_PSEUDO_ELEMENTS = re.compile(r'::(?:-webkit-[\w-]+|-moz-[\w-]+|backdrop)\b')
|
||||
WORD_PATTERN = re.compile(r'(?<![\w-])[a-zA-Z_][\w-]*(?![\w-])')
|
||||
VENDOR_SELECTOR = re.compile(r'::?-(webkit|moz|ms|o)-[\w-]+')
|
||||
|
||||
|
||||
def panel_sources() -> list[Path]:
|
||||
if __package__:
|
||||
from .build.panel_css import source_files
|
||||
else:
|
||||
from build.panel_css import source_files
|
||||
return source_files(ROOT)
|
||||
|
||||
|
||||
def embedded_javascript_sources() -> list[Path]:
|
||||
paths = set()
|
||||
for parent in (ROOT / 'src').rglob('*.rs'):
|
||||
for relative in re.findall(r'\binclude_str!\s*\(\s*"([^"\n]+\.js)"\s*\)', parent.read_text(encoding='utf-8')):
|
||||
path = (parent.parent / relative).resolve()
|
||||
if path.is_relative_to(WEB) and path.is_file():
|
||||
paths.add(path)
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def structural_inventory(paths: list[Path]) -> dict:
|
||||
"""Report repetitions in their actual conditional scope; never reorder CSS."""
|
||||
selectors = defaultdict(list)
|
||||
conditions = defaultdict(list)
|
||||
identical_rules = defaultdict(list)
|
||||
identical_blocks = defaultdict(list)
|
||||
properties = []
|
||||
parse_errors = []
|
||||
|
||||
def walk(items, path, context=()):
|
||||
for item in items:
|
||||
location = f'{path.relative_to(ROOT)}:{item.source_line}'
|
||||
if item.type == 'error':
|
||||
parse_errors.append({'location': location, 'message': item.message})
|
||||
elif item.type == 'qualified-rule':
|
||||
parts = selector_parts(item.prelude)
|
||||
declarations = []
|
||||
property_locations = defaultdict(list)
|
||||
for declaration in tinycss2.parse_declaration_list(item.content, skip_whitespace=True, skip_comments=True):
|
||||
if declaration.type == 'error':
|
||||
parse_errors.append({'location': location, 'message': declaration.message})
|
||||
elif declaration.type == 'declaration':
|
||||
name = declaration.name if declaration.name.startswith('--') else declaration.lower_name
|
||||
value = tinycss2.serialize(declaration.value).strip()
|
||||
declarations.append((name, value, declaration.important))
|
||||
property_locations[name].append(declaration.source_line)
|
||||
for name, lines in property_locations.items():
|
||||
if len(lines) > 1:
|
||||
properties.append({'location': location, 'selector': ', '.join(parts), 'property': name, 'lines': lines})
|
||||
for selector in parts:
|
||||
selectors[(context, selector)].append(location)
|
||||
identical_rules[(context, tuple(parts), tuple(declarations))].append(location)
|
||||
if declarations:
|
||||
identical_blocks[(context, tuple(declarations))].append({'location': location, 'selectors': parts})
|
||||
elif item.type == 'at-rule' and item.content is not None:
|
||||
prelude = re.sub(r'\s+', ' ', tinycss2.serialize(item.prelude).strip())
|
||||
prelude = re.sub(r'\s*([():,])\s*', r'\1', prelude)
|
||||
label = f'@{item.lower_at_keyword} {prelude}'
|
||||
if item.lower_at_keyword in ('media', 'container', 'supports'):
|
||||
conditions[(context, label)].append(location)
|
||||
if item.lower_at_keyword in ('media', 'container', 'supports', 'layer', 'keyframes', '-webkit-keyframes'):
|
||||
walk(tinycss2.parse_rule_list(item.content, skip_whitespace=True, skip_comments=True), path, context + (label,))
|
||||
|
||||
for path in paths:
|
||||
walk(tinycss2.parse_stylesheet(path.read_text(encoding='utf-8'), skip_whitespace=True, skip_comments=True), path)
|
||||
repeated_bodies = []
|
||||
for (scope, _), entries in identical_blocks.items():
|
||||
if len(entries) < 2:
|
||||
continue
|
||||
families = [tuple(sorted({family for selector in entry['selectors']
|
||||
for family in VENDOR_SELECTOR.findall(selector)}))
|
||||
for entry in entries]
|
||||
# A browser can reject an entire comma-separated selector list when it
|
||||
# contains another engine's pseudo-element. Keep one body per compatible
|
||||
# family, while still rejecting copies within the same family.
|
||||
vendor_only = (any(families) and all(len(family) <= 1 for family in families)
|
||||
and len(families) == len(set(families)))
|
||||
repeated_bodies.append({'scope': list(scope), 'rules': entries,
|
||||
'vendor_compatibility_only': vendor_only})
|
||||
return {
|
||||
'repeated_selectors': [{'scope': list(scope), 'selector': selector, 'locations': locations}
|
||||
for (scope, selector), locations in selectors.items() if len(locations) > 1],
|
||||
'repeated_conditions': [{'scope': list(scope), 'condition': condition, 'locations': locations}
|
||||
for (scope, condition), locations in conditions.items() if len(locations) > 1],
|
||||
'identical_rules': [{'scope': list(scope), 'selectors': list(parts), 'locations': locations}
|
||||
for (scope, parts, _), locations in identical_rules.items() if len(locations) > 1],
|
||||
'identical_declaration_blocks': repeated_bodies,
|
||||
'repeated_properties': properties,
|
||||
'parse_errors': parse_errors,
|
||||
}
|
||||
|
||||
|
||||
def source_words(scope: str) -> set[str]:
|
||||
paths = list(WEB.rglob('*.js')) + list(WEB.rglob('*.html'))
|
||||
# Rust's include_str! lists are the asset entry points; an abandoned JS file
|
||||
# beside a bundle must not make its obsolete selectors appear live.
|
||||
paths = embedded_javascript_sources() + list(WEB.rglob('*.html'))
|
||||
if scope == 'panel':
|
||||
paths += list(WEB.rglob('*.json')) + list((ROOT / 'src').rglob('*.rs'))
|
||||
else:
|
||||
@@ -80,8 +172,8 @@ def token_refs(node) -> list[tuple[str, str]]:
|
||||
|
||||
|
||||
def possibly_matches(node, words: set[str]) -> bool:
|
||||
# Deliberately conservative: class/id absence proves a positive selector
|
||||
# impossible but does NOT make a selector in :not() impossible.
|
||||
# Only positive class/id requirements identify review candidates. Runtime
|
||||
# interpolation still requires manual inspection before removing a rule.
|
||||
if isinstance(node, css_parser.ClassSelector):
|
||||
return node.class_name in words
|
||||
if isinstance(node, css_parser.IDSelector):
|
||||
@@ -125,7 +217,9 @@ def audit_file(path: Path, words: set[str], scope: str) -> dict:
|
||||
if external:
|
||||
dead = False
|
||||
missing = sorted({f'{typ}:{word}' for typ, word in references if word not in words})
|
||||
parts.append({'selector': selector, 'dead': dead, 'missing_tokens': missing,
|
||||
# Retain the historical JSON key for older audit consumers;
|
||||
# "dead" is a candidate flag, never proof of runtime absence.
|
||||
parts.append({'selector': selector, 'dead': dead, 'review_required': dead, 'missing_tokens': missing,
|
||||
'external': external})
|
||||
except css_parser.SelectorError as exc:
|
||||
parts.append({'selector': selector, 'dead': False, 'missing_tokens': [], 'unparsed': str(exc)})
|
||||
@@ -146,13 +240,21 @@ def audit_file(path: Path, words: set[str], scope: str) -> dict:
|
||||
def main():
|
||||
cli = argparse.ArgumentParser(description=__doc__)
|
||||
cli.add_argument('--json', type=Path, help='Write complete selector audit JSON to this file')
|
||||
cli.add_argument('--structure-json', type=Path, help='Write cross-module duplicate inventory JSON to this file')
|
||||
cli.add_argument('--strict', action='store_true', help='Fail on repeated panel selectors/conditions/rules/properties, compatible declaration blocks or parse errors')
|
||||
args = cli.parse_args()
|
||||
results = [audit_file(WEB / filename, source_words('docs' if filename == 'docs.css' else 'panel'),
|
||||
'docs' if filename == 'docs.css' else 'panel') for filename in STYLES]
|
||||
panel = panel_sources()
|
||||
orphan_scripts = sorted(set(WEB.rglob('*.js')) - set(embedded_javascript_sources()))
|
||||
if orphan_scripts:
|
||||
print('JavaScript outside embedded asset entry points (review):', ', '.join(str(path.relative_to(ROOT)) for path in orphan_scripts))
|
||||
paths = [*panel, WEB / 'docs.css']
|
||||
words = {'panel': source_words('panel'), 'docs': source_words('docs')}
|
||||
results = [audit_file(path, words['docs' if path.name == 'docs.css' else 'panel'],
|
||||
'docs' if path.name == 'docs.css' else 'panel') for path in paths]
|
||||
for r in results:
|
||||
print(f"{r['file']}: {r['total_rules']} rules, {r['total_selectors']} selector branches, "
|
||||
f"{len(r['referenced_classes'])} referenced classes, {len(r['unreferenced_classes'])} unreferenced classes, "
|
||||
f"{r['unused_selector_count']} provably dead selector branches, {len(r['unparsed'])} unparsed")
|
||||
f"{r['unused_selector_count']} selector branches requiring review, {len(r['unparsed'])} unparsed")
|
||||
print(' Unreferenced:', ', '.join(r['unreferenced_classes']) or '(none)')
|
||||
for e in r['entries']:
|
||||
for p in e['parts']:
|
||||
@@ -160,6 +262,15 @@ def main():
|
||||
print(f" {e['line']}: {p['selector'][:170]} ({', '.join(p['missing_tokens'])})")
|
||||
if args.json:
|
||||
args.json.write_text(json.dumps(results, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
structure = structural_inventory(panel)
|
||||
print('Panel structure:', ', '.join(f'{len(entries)} {name.replace("_", " ")}' for name, entries in structure.items()))
|
||||
if args.structure_json:
|
||||
args.structure_json.write_text(json.dumps(structure, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
failures = [name for name in ('repeated_selectors', 'repeated_conditions', 'identical_rules', 'repeated_properties', 'parse_errors') if structure[name]]
|
||||
if any(not group['vendor_compatibility_only'] for group in structure['identical_declaration_blocks']):
|
||||
failures.append('identical_declaration_blocks')
|
||||
if args.strict and failures:
|
||||
raise SystemExit('Panel CSS structure check failed: ' + ', '.join(failures))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""RustProxy build helpers; keep local imports separate from external build packages."""
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the embedded panel stylesheet in manifest order, without npm or imports.
|
||||
|
||||
Only comments and formatting whitespace beside rule/declaration delimiters are
|
||||
minified. Selector whitespace, escapes, strings, functions (including calc and
|
||||
url), declaration values and rule order are otherwise retained verbatim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS_DIRECTORY = Path("src/admin/web/css")
|
||||
FILENAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\.css\Z")
|
||||
WHITESPACE = " \t\n\r\f"
|
||||
HEX = "0123456789abcdefABCDEF"
|
||||
DELIMITERS = frozenset("{};")
|
||||
|
||||
|
||||
def _escape_end(text: str, start: int) -> int:
|
||||
end = start + 1
|
||||
if end == len(text):
|
||||
raise ValueError("Unterminated CSS escape")
|
||||
if text[end] in HEX:
|
||||
limit = min(end + 6, len(text))
|
||||
while end < limit and text[end] in HEX:
|
||||
end += 1
|
||||
if end < len(text) and text[end] in WHITESPACE:
|
||||
if text[end:end + 2] == "\r\n":
|
||||
return end + 2
|
||||
end += 1
|
||||
return end
|
||||
return end + (2 if text[end:end + 2] == "\r\n" else 1)
|
||||
|
||||
|
||||
def _string_end(text: str, start: int) -> int:
|
||||
end = start + 1
|
||||
while end < len(text):
|
||||
if text[end] == text[start]:
|
||||
return end + 1
|
||||
if text[end] == "\\":
|
||||
end = _escape_end(text, end)
|
||||
elif text[end] in "\n\r\f":
|
||||
raise ValueError("Unescaped newline in CSS string")
|
||||
else:
|
||||
end += 1
|
||||
raise ValueError("Unterminated CSS string")
|
||||
|
||||
|
||||
def _comment_end(text: str, start: int) -> int:
|
||||
end = text.find("*/", start + 2)
|
||||
if end < 0:
|
||||
raise ValueError("Unterminated CSS comment")
|
||||
return end + 2
|
||||
|
||||
|
||||
def _group_end(text: str, start: int) -> int:
|
||||
# Do not rewrite anything in functions or attribute selectors. This also
|
||||
# protects unquoted data URLs and whitespace required by calc()/CSS escapes.
|
||||
closing = ")" if text[start] == "(" else "]"
|
||||
is_url = (text[start] == "(" and text[max(0, start - 3):start].lower() == "url"
|
||||
and (start < 4 or not (text[start - 4].isalnum() or text[start - 4] in "_-")))
|
||||
end = start + 1
|
||||
while end < len(text):
|
||||
if text[end] == closing:
|
||||
return end + 1
|
||||
if text[end] in "\"'":
|
||||
end = _string_end(text, end)
|
||||
elif text[end] == "\\":
|
||||
end = _escape_end(text, end)
|
||||
elif text.startswith("/*", end) and not is_url:
|
||||
end = _comment_end(text, end)
|
||||
elif text[end] in "([" and not is_url:
|
||||
end = _group_end(text, end)
|
||||
elif text[end] in ")]" and not is_url:
|
||||
raise ValueError("Mismatched CSS delimiter")
|
||||
else:
|
||||
# In an unquoted url(), /* and square brackets are literal data.
|
||||
end += 1
|
||||
raise ValueError(f"Unterminated CSS {text[start]} group")
|
||||
|
||||
|
||||
def minify(text: str) -> str:
|
||||
"""Conservatively compact a stylesheet without reordering its CSS tokens."""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
position = 0
|
||||
depth = 0
|
||||
while position < len(text):
|
||||
char = text[position]
|
||||
if char in WHITESPACE:
|
||||
end = position + 1
|
||||
while end < len(text) and text[end] in WHITESPACE:
|
||||
end += 1
|
||||
kind = "space"
|
||||
elif text.startswith("/*", position):
|
||||
end, kind = _comment_end(text, position), "comment"
|
||||
elif char in "\"'":
|
||||
end, kind = _string_end(text, position), "protected"
|
||||
elif char == "\\":
|
||||
end, kind = _escape_end(text, position), "protected"
|
||||
elif char in "([":
|
||||
end, kind = _group_end(text, position), "protected"
|
||||
else:
|
||||
end, kind = position + 1, "plain"
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise ValueError("Unexpected closing CSS brace")
|
||||
elif char in ")]":
|
||||
raise ValueError("Unexpected closing CSS delimiter")
|
||||
tokens.append((kind, text[position:end]))
|
||||
position = end
|
||||
if depth:
|
||||
raise ValueError("Unterminated CSS rule")
|
||||
|
||||
# Source modules must stay independent of runtime imports and encoding
|
||||
# declarations; all are UTF-8 and are delivered as one embedded response.
|
||||
for index, token in enumerate(tokens):
|
||||
if token != ("plain", "@"):
|
||||
continue
|
||||
name = ""
|
||||
for offset in range(index + 1, len(tokens)):
|
||||
kind, value = tokens[offset]
|
||||
if kind == "plain" and (value.isalnum() or value in "_-"):
|
||||
name += value
|
||||
elif kind == "protected" and value.startswith("\\"):
|
||||
escaped = value[1:]
|
||||
if escaped and escaped[0] in HEX:
|
||||
codepoint = int(escaped.rstrip(WHITESPACE), 16)
|
||||
name += chr(codepoint) if 0 < codepoint <= 0x10FFFF else "\ufffd"
|
||||
else:
|
||||
name += escaped
|
||||
else:
|
||||
break
|
||||
if name.lower() in ("import", "charset"):
|
||||
raise ValueError("CSS modules must not contain @import or @charset")
|
||||
|
||||
compact: list[tuple[str, str]] = []
|
||||
for index, (kind, value) in enumerate(tokens):
|
||||
if kind == "comment":
|
||||
if value.startswith("/*!"):
|
||||
compact.append(("protected", value))
|
||||
continue
|
||||
before = tokens[index - 1] if index else None
|
||||
after = tokens[index + 1] if index + 1 < len(tokens) else None
|
||||
if (before and after and before[0] != "space" and after[0] != "space"
|
||||
and not (before[0] == "plain" and before[1] in DELIMITERS)
|
||||
and not (after[0] == "plain" and after[1] in DELIMITERS)):
|
||||
# Retain a token boundary: e.g. --value:foo/**/bar must never
|
||||
# become the single identifier foobar. No guessed whitespace.
|
||||
compact.append(("protected", "/**/"))
|
||||
continue
|
||||
if kind == "space" and compact and compact[-1][0] == "space":
|
||||
compact[-1] = ("space", compact[-1][1] + value)
|
||||
else:
|
||||
compact.append((kind, value))
|
||||
|
||||
output: list[str] = []
|
||||
for index, (kind, value) in enumerate(compact):
|
||||
if kind == "space":
|
||||
before = compact[index - 1] if index else None
|
||||
after = compact[index + 1] if index + 1 < len(compact) else None
|
||||
if (not before or not after
|
||||
or (before[0] == "plain" and before[1] in DELIMITERS)
|
||||
or (after[0] == "plain" and after[1] in DELIMITERS)):
|
||||
continue
|
||||
output.append(value)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def source_files(root: Path = ROOT) -> list[Path]:
|
||||
directory = root / CSS_DIRECTORY
|
||||
manifest = directory / "manifest.json"
|
||||
if directory.is_symlink() or manifest.is_symlink():
|
||||
raise ValueError("Panel CSS directory and manifest must not be symlinks")
|
||||
config = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(config, dict) or set(config) != {"files"}:
|
||||
raise ValueError('CSS manifest must be an object containing only a "files" array')
|
||||
names = config["files"]
|
||||
if (not isinstance(names, list) or not names
|
||||
or not all(isinstance(name, str) and FILENAME.fullmatch(name) for name in names)):
|
||||
raise ValueError("CSS manifest must list CSS basenames without directory components")
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("CSS manifest lists a source more than once")
|
||||
actual = {path.name for path in directory.iterdir() if path.suffix.lower() == ".css"}
|
||||
if set(names) != actual:
|
||||
missing = sorted(set(names) - actual)
|
||||
unlisted = sorted(actual - set(names))
|
||||
raise ValueError(f"CSS manifest mismatch: missing={missing}; unlisted={unlisted}")
|
||||
paths = [directory / name for name in names]
|
||||
for path in paths:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError(f"CSS source must be a regular, non-symlink file: {path.name}")
|
||||
return paths
|
||||
|
||||
|
||||
def bundle(root: Path = ROOT) -> tuple[str, list[Path], int]:
|
||||
paths = source_files(root)
|
||||
parts: list[str] = []
|
||||
source_bytes = 0
|
||||
for path in paths:
|
||||
raw = path.read_bytes()
|
||||
source_bytes += len(raw)
|
||||
try:
|
||||
parts.append(minify(raw.decode("utf-8")))
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{path.name}: {error}") from error
|
||||
return "\n".join(parts) + "\n", paths, source_bytes
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=ROOT)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--out-dir", type=Path, help="Write panel-style.css here")
|
||||
mode.add_argument("--check", action="store_true", help="Validate sources without writing files")
|
||||
parser.add_argument("--cargo", action="store_true", help="Emit Cargo input/output dependencies")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
root = args.root.resolve()
|
||||
result, paths, source_bytes = bundle(root)
|
||||
encoded = result.encode("utf-8")
|
||||
if args.out_dir is not None:
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = args.out_dir / "panel-style.css"
|
||||
if not destination.exists() or destination.read_bytes() != encoded:
|
||||
destination.write_bytes(encoded)
|
||||
if args.cargo:
|
||||
watched = [root / CSS_DIRECTORY, root / CSS_DIRECTORY / "manifest.json", *paths]
|
||||
if args.out_dir is not None:
|
||||
# Cargo must rerun the generator when its cached output is
|
||||
# removed even if every source module is unchanged.
|
||||
watched.append(args.out_dir.resolve() / "panel-style.css")
|
||||
for path in watched:
|
||||
if any(char in str(path) for char in "\r\n\0"):
|
||||
raise ValueError("CSS build dependency path contains a control character")
|
||||
print(f"cargo:rerun-if-changed={path}")
|
||||
else:
|
||||
print(f"Panel CSS: {len(paths)} modules, {source_bytes} source bytes, "
|
||||
f"{len(encoded)} bundled bytes; cascade order preserved.")
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"Panel CSS preparation failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+74
-1
@@ -8,10 +8,74 @@ from pathlib import Path
|
||||
import sys
|
||||
|
||||
LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85"
|
||||
LEGACY_PANEL_CSS = "38716fae968aeb8f6a8510f28bb9054a73a438a969c3b69f9c9d921acfed6e06"
|
||||
|
||||
|
||||
def validate_panel_css_build(root: Path) -> None:
|
||||
"""Check this project's build.rs startup contract, not arbitrary Rust code."""
|
||||
source = (root / "build.rs").read_text(encoding="utf-8")
|
||||
# Ignore ordinary strings and comments, including nested block comments.
|
||||
# The required call precedes the first nested block in the shipped main().
|
||||
code: list[str] = []
|
||||
position = 0
|
||||
token = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*', re.DOTALL)
|
||||
while match := token.search(source, position):
|
||||
code.extend((source[position:match.start()], " "))
|
||||
position = match.end()
|
||||
if match.group() == "/*":
|
||||
depth = 1
|
||||
while depth:
|
||||
boundary = re.search(r'/\*|\*/', source[position:])
|
||||
if boundary is None:
|
||||
position = len(source)
|
||||
break
|
||||
depth += 1 if boundary.group() == "/*" else -1
|
||||
position += boundary.end()
|
||||
code.append(source[position:])
|
||||
main = re.search(r'\bfn\s+main\s*\(\s*\)[^{]*\{([^{}]*)', "".join(code))
|
||||
if main is None or re.search(
|
||||
r'\bpanel_styles\s*\(\s*&root\s*,\s*&out\s*,?\s*\)\s*\?\s*;',
|
||||
main.group(1),
|
||||
) is None:
|
||||
raise ValueError(
|
||||
"Mixed modular panel CSS source tree: build.rs is missing the expected "
|
||||
"panel_styles(&root, &out)? call in main's startup block. Restore build.rs "
|
||||
"from the complete matching source archive, preserving your local edits, "
|
||||
"then run 'touch build.rs' and retry ./dev.sh. "
|
||||
"Automatic repair does not rewrite build.rs."
|
||||
)
|
||||
|
||||
|
||||
def preserve_legacy_panel_css(root: Path, repair_known: bool = False) -> list[str]:
|
||||
old = root / "src/admin/web/style.css"
|
||||
manifest = root / "src/admin/web/css/manifest.json"
|
||||
if not manifest.is_file() or not (old.exists() or old.is_symlink()):
|
||||
return []
|
||||
if old.is_symlink() or hashlib.sha256(old.read_bytes()).hexdigest() != LEGACY_PANEL_CSS:
|
||||
raise ValueError(
|
||||
"Obsolete src/admin/web/style.css remains beside the modular CSS sources and "
|
||||
"contains unknown/custom content. Preserve it outside src/admin/web, review "
|
||||
"your changes and port them into the CSS modules before rebuilding. "
|
||||
"Automatic repair does not move or discard custom styles."
|
||||
)
|
||||
if not repair_known:
|
||||
raise ValueError(
|
||||
"Known obsolete 0.34.9 src/admin/web/style.css remains beside the modular CSS sources. "
|
||||
"Run ./dev.sh repair to preserve it under .upgrade-backup/panel-css/."
|
||||
)
|
||||
target = root / ".upgrade-backup/panel-css/style.css.0.34.9"
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_symlink() or target.read_bytes() != old.read_bytes():
|
||||
raise ValueError("Legacy CSS backup already contains different data; refusing to overwrite it")
|
||||
old.unlink() # The exact original is already backed up.
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
old.rename(target)
|
||||
return [f"Preserved obsolete panel CSS as {target.relative_to(root)}"]
|
||||
|
||||
|
||||
def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
messages: list[str] = []
|
||||
messages: list[str] = preserve_legacy_panel_css(root, repair_known)
|
||||
obsolete_split_files = (
|
||||
"src/admin/web/app.js",
|
||||
"src/admin/web/vhosts.js",
|
||||
@@ -71,6 +135,15 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
|
||||
for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"):
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete modular panel CSS source tree: missing {path}")
|
||||
validate_panel_css_build(root)
|
||||
if __package__:
|
||||
from .build.panel_css import bundle
|
||||
else:
|
||||
from build.panel_css import bundle
|
||||
bundle(root) # Validate the manifest and every module without generating files.
|
||||
registry = root / "src/admin/documentation/documents.rs"
|
||||
if not registry.is_file():
|
||||
raise ValueError("Incomplete RustProxy 0.34.2 source tree: missing src/admin/documentation/documents.rs")
|
||||
|
||||
Reference in new issue
Block a user