Add on-demand CSS: registry, pre-computed component classes, header compression
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 42s

- Parse tw.css into per-class lookup registry at startup
- Pre-scan component CSS classes at registration time (avoid per-request regex)
- Compress SX-Css header: 8-char hash replaces full class list (LRU cache)
- Add ;@css comment annotation for dynamically constructed class names
- Safelist bg-sky-{100..400} in Tailwind config for menu-row-sx dynamic shades
- Client sends/receives hash, falls back gracefully on cache miss

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:39:57 +00:00
parent ab45e21c7c
commit 1447122a0c
12 changed files with 603 additions and 31 deletions

View File

@@ -1,15 +1,8 @@
<style> <style>
@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } } @media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }
</style> </style>
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/basics.css')}}">
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/cards.css')}}">
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<script src="https://cdn.tailwindcss.com?plugins=typography"></script> <style id="sx-css">{{ sx_css_all() }}</style>
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">
<link rel="stylesheet" href="{{asset_url('fontawesome/css/v4-shims.min.css')}}">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet" />
<script src="https://unpkg.com/prismjs/prism.js"></script> <script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script> <script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script> <script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>

View File

@@ -117,6 +117,24 @@ def create_base_app(
load_shared_components() load_shared_components()
load_relation_registry() load_relation_registry()
# Load CSS registry (tw.css → class-to-rule lookup for on-demand CSS)
from shared.sx.css_registry import load_css_registry, registry_loaded
_styles = BASE_DIR / "static" / "styles"
_fa_css = BASE_DIR / "static" / "fontawesome" / "css"
if (_styles / "tw.css").exists() and not registry_loaded():
load_css_registry(
_styles / "tw.css",
extra_css=[
_styles / "basics.css",
_styles / "cards.css",
_styles / "blog-content.css",
_styles / "prism.css",
_fa_css / "all.min.css",
_fa_css / "v4-shims.min.css",
],
url_rewrites={"../webfonts/": "/static/fontawesome/webfonts/"},
)
# Dev-mode: auto-reload sx templates when files change on disk # Dev-mode: auto-reload sx templates when files change on disk
if os.getenv("RELOAD") == "true": if os.getenv("RELOAD") == "true":
from shared.sx.jinja_bridge import reload_if_changed from shared.sx.jinja_bridge import reload_if_changed
@@ -298,7 +316,7 @@ def create_base_app(
response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Headers"] = ( response.headers["Access-Control-Allow-Headers"] = (
"SX-Request, SX-Target, SX-Current-URL, SX-Components, " "SX-Request, SX-Target, SX-Current-URL, SX-Components, SX-Css, "
"HX-Request, HX-Target, HX-Current-URL, HX-Trigger, " "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, "
"Content-Type, X-CSRFToken" "Content-Type, X-CSRFToken"
) )

View File

@@ -1520,6 +1520,10 @@
}); });
if (loadedNames.length) headers["SX-Components"] = loadedNames.join(","); if (loadedNames.length) headers["SX-Components"] = loadedNames.join(",");
// Send known CSS classes so server only sends new rules
var cssHeader = _getSxCssHeader();
if (cssHeader) headers["SX-Css"] = cssHeader;
// Extra headers from sx-headers // Extra headers from sx-headers
var extraH = el.getAttribute("sx-headers"); var extraH = el.getAttribute("sx-headers");
if (extraH) { if (extraH) {
@@ -1647,6 +1651,8 @@
// Strip and load any <script type="text/sx" data-components> blocks // Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; }); function (_, defs) { Sx.loadComponents(defs); return ""; });
// Process on-demand CSS: extract <style data-sx-css> and inject into head
text = _processCssResponse(text, resp);
var sxSource = text.trim(); var sxSource = text.trim();
// Parse and render to live DOM nodes (skip renderToString + DOMParser) // Parse and render to live DOM nodes (skip renderToString + DOMParser)
@@ -2150,9 +2156,12 @@
var main = document.getElementById("main-panel"); var main = document.getElementById("main-panel");
if (!main) { location.reload(); return; } if (!main) { location.reload(); return; }
var histOpts = { var histHeaders = { "SX-Request": "true", "SX-History-Restore": "true" };
headers: { "SX-Request": "true", "SX-History-Restore": "true" } var cssH = _getSxCssHeader();
}; if (cssH) histHeaders["SX-Css"] = cssH;
var loadedN = Object.keys(_componentEnv).filter(function (k) { return k.charAt(0) === "~"; });
if (loadedN.length) histHeaders["SX-Components"] = loadedN.join(",");
var histOpts = { headers: histHeaders };
try { try {
var hHost = new URL(url, location.href).hostname; var hHost = new URL(url, location.href).hostname;
if (hHost !== location.hostname && if (hHost !== location.hostname &&
@@ -2162,11 +2171,15 @@
} catch (e) {} } catch (e) {}
fetch(url, histOpts).then(function (resp) { fetch(url, histOpts).then(function (resp) {
return resp.text(); return resp.text().then(function (t) { return { text: t, resp: resp }; });
}).then(function (text) { }).then(function (r) {
var text = r.text;
var resp = r.resp;
// Strip and load any <script type="text/sx" data-components> blocks // Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; }); function (_, defs) { Sx.loadComponents(defs); return ""; });
// Process on-demand CSS
text = _processCssResponse(text, resp);
text = text.trim(); text = text.trim();
if (text.charAt(0) === "(") { if (text.charAt(0) === "(") {
@@ -2279,11 +2292,64 @@
// Auto-init in browser // Auto-init in browser
// ========================================================================= // =========================================================================
Sx.VERSION = "2026-03-01b-debug"; Sx.VERSION = "2026-03-01c-cssx";
// CSS class tracking for on-demand CSS delivery
var _sxCssKnown = {};
var _sxCssHash = ""; // 8-char hex hash from server
function _initCssTracking() {
var meta = document.querySelector('meta[name="sx-css-classes"]');
if (meta) {
var content = meta.getAttribute("content");
if (content) {
// If content is short (≤16 chars), it's a hash from the server
if (content.length <= 16) {
_sxCssHash = content;
} else {
content.split(",").forEach(function (c) {
if (c) _sxCssKnown[c] = true;
});
}
}
}
}
function _getSxCssHeader() {
// Prefer sending the hash (compact) over the full class list
if (_sxCssHash) return _sxCssHash;
var names = Object.keys(_sxCssKnown);
return names.length ? names.join(",") : "";
}
function _processCssResponse(text, resp) {
// Read SX-Css-Hash response header — replaces local hash
var hashHeader = resp.headers.get("SX-Css-Hash");
if (hashHeader) _sxCssHash = hashHeader;
// Merge SX-Css-Add header into known set (kept for debugging/fallback)
var addHeader = resp.headers.get("SX-Css-Add");
if (addHeader) {
addHeader.split(",").forEach(function (c) {
if (c) _sxCssKnown[c] = true;
});
}
// Extract <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
var cssTarget = document.getElementById("sx-css");
if (cssTarget) {
text = text.replace(/<style[^>]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi,
function (_, css) {
cssTarget.textContent += css;
return "";
});
}
return text;
}
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
var init = function () { var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init"); console.log("[sx.js] v" + Sx.VERSION + " init");
_initCssTracking();
Sx.processScripts(); Sx.processScripts();
Sx.hydrate(); Sx.hydrate();
SxEngine.process(); SxEngine.process();

View File

@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
safelist: [
// ~menu-row-sx builds bg-{colour}-{shade} dynamically via (str ...)
// Levels 14 produce shades 400100 (level 5+ yields 0 or negative = no match)
{ pattern: /^bg-sky-(100|200|300|400)$/ },
],
content: [
'/root/rose-ash/shared/sx/templates/**/*.sx',
'/root/rose-ash/shared/browser/templates/**/*.html',
'/root/rose-ash/blog/sx/**/*.sx',
'/root/rose-ash/blog/browser/templates/**/*.html',
'/root/rose-ash/market/sx/**/*.sx',
'/root/rose-ash/market/browser/templates/**/*.html',
'/root/rose-ash/cart/sx/**/*.sx',
'/root/rose-ash/cart/browser/templates/**/*.html',
'/root/rose-ash/events/sx/**/*.sx',
'/root/rose-ash/events/browser/templates/**/*.html',
'/root/rose-ash/federation/sx/**/*.sx',
'/root/rose-ash/federation/browser/templates/**/*.html',
'/root/rose-ash/account/sx/**/*.sx',
'/root/rose-ash/account/browser/templates/**/*.html',
'/root/rose-ash/orders/sx/**/*.sx',
'/root/rose-ash/orders/browser/templates/**/*.html',
'/root/rose-ash/shared/sx/helpers.py',
'/root/rose-ash/blog/sx/sx_components.py',
'/root/rose-ash/market/sx/sx_components.py',
'/root/rose-ash/cart/sx/sx_components.py',
'/root/rose-ash/events/sx/sx_components.py',
'/root/rose-ash/federation/sx/sx_components.py',
'/root/rose-ash/account/sx/sx_components.py',
'/root/rose-ash/orders/sx/sx_components.py',
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}

File diff suppressed because one or more lines are too long

327
shared/sx/css_registry.py Normal file
View File

@@ -0,0 +1,327 @@
"""
On-demand CSS registry — parses tw.css at startup into a lookup table.
Maps HTML class names (e.g. "flex", "sm:hidden", "h-[60vh]") to their
CSS rule text. The server uses this to send only the CSS rules needed
for each response, instead of the full Tailwind bundle.
Usage::
load_css_registry("/path/to/tw.css")
rules = lookup_rules({"flex", "p-2", "sm:hidden"})
preamble = get_preamble()
"""
from __future__ import annotations
import hashlib
import re
from collections import OrderedDict
from pathlib import Path
from typing import Sequence
# ---------------------------------------------------------------------------
# Module state
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, str] = {} # class name → CSS rule text
_RULE_ORDER: dict[str, int] = {} # class name → source order index
_PREAMBLE: str = "" # base/reset CSS (sent once per page)
_ALL_RULES: str = "" # full concatenated rules (for Jinja fallback)
# Hash cache: maps 8-char hex hash → frozenset of class names
_CSS_HASH_CACHE: OrderedDict[str, frozenset[str]] = OrderedDict()
_CSS_HASH_CACHE_MAX = 1000
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def load_css_registry(
path: str | Path,
*,
extra_css: Sequence[str | Path] = (),
url_rewrites: dict[str, str] | None = None,
) -> None:
"""Parse a Tailwind v3 CSS file and populate the registry.
Parameters
----------
path:
Path to the main Tailwind CSS file (tw.css).
extra_css:
Additional CSS files to include in the preamble (inlined verbatim).
These are loaded in order and prepended before the Tailwind rules.
url_rewrites:
Dict of ``{old_prefix: new_prefix}`` applied to all extra CSS files.
e.g. ``{"../webfonts/": "/static/fontawesome/webfonts/"}``
"""
global _PREAMBLE, _ALL_RULES
_REGISTRY.clear()
_RULE_ORDER.clear()
css_path = Path(path)
css = css_path.read_text(encoding="utf-8")
rewrites = url_rewrites or {}
# Load extra CSS files into a combined prefix
sibling_css = ""
for extra in extra_css:
p = Path(extra)
if p.exists():
content = p.read_text(encoding="utf-8")
for old, new in rewrites.items():
content = content.replace(old, new)
sibling_css += content
# Split into preamble (resets, vars) and utility rules.
# Tailwind v3 minified structure:
# - Custom property defaults (*,:after,:before{--tw-...})
# - Base resets (html, body, etc.)
# - Utility classes (.flex{...}, .hidden{...})
# - Responsive variants (@media ...{.sm\:...{...}})
#
# We treat everything before the first utility class selector as preamble.
# A utility class selector starts with "." followed by a word char or backslash.
preamble_parts: list[str] = []
utility_css = css
# Find first bare utility class (not inside a reset block)
# Heuristic: the preamble ends at the first rule whose selector is ONLY
# a class (starts with . and no *, :, html, body, etc.)
#
# More robust: walk rules and detect when selectors become single-class.
rules = _split_rules(css)
preamble_end = 0
for i, rule in enumerate(rules):
sel = _extract_selector(rule)
if sel and _is_utility_selector(sel):
preamble_end = i
break
_PREAMBLE = sibling_css + "".join(rules[:preamble_end])
_index_rules(rules[preamble_end:])
_ALL_RULES = _PREAMBLE + "".join(
_REGISTRY[k] for k in sorted(_REGISTRY, key=lambda k: _RULE_ORDER.get(k, 0))
)
def get_preamble() -> str:
"""Return the preamble CSS (resets + custom property defaults)."""
return _PREAMBLE
def get_all_css() -> str:
"""Return preamble + all utility rules (for Jinja fallback pages)."""
return _ALL_RULES
def lookup_rules(classes: set[str]) -> str:
"""Return concatenated CSS for a set of class names, preserving source order."""
found = [(name, _RULE_ORDER.get(name, 0)) for name in classes if name in _REGISTRY]
found.sort(key=lambda t: t[1])
return "".join(_REGISTRY[name] for name, _ in found)
def scan_classes_from_sx(source: str) -> set[str]:
"""Extract class names from :class "..." patterns in sx source text.
Works on both component definitions and page sx source.
Also picks up classes from :class (str ...) concatenation patterns
and ``;; @css class1 class2 ...`` comment annotations for dynamically
constructed class names that the regex can't infer.
"""
classes: set[str] = set()
# Match :class "value" — the common case
for m in re.finditer(r':class\s+"([^"]*)"', source):
classes.update(m.group(1).split())
# Match :class (str "a" " b" ...) — string concatenation
for m in re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source):
for s in re.findall(r'"([^"]*)"', m.group(1)):
classes.update(s.split())
# Match ;; @css class1 class2 ... — explicit hints for dynamic classes
for m in re.finditer(r';\s*@css\s+(.+)', source):
classes.update(m.group(1).split())
return classes
def registry_loaded() -> bool:
"""True if the registry has been populated."""
return bool(_REGISTRY)
def store_css_hash(classes: set[str] | frozenset[str]) -> str:
"""Compute an 8-char hex hash of the class set, store in cache, return it."""
fs = frozenset(classes)
key = hashlib.sha256(",".join(sorted(fs)).encode()).hexdigest()[:8]
# Move to end (LRU) or insert
_CSS_HASH_CACHE[key] = fs
_CSS_HASH_CACHE.move_to_end(key)
# Evict oldest if over limit
while len(_CSS_HASH_CACHE) > _CSS_HASH_CACHE_MAX:
_CSS_HASH_CACHE.popitem(last=False)
return key
def lookup_css_hash(h: str) -> set[str] | None:
"""Look up a class set by its hash. Returns None on cache miss."""
fs = _CSS_HASH_CACHE.get(h)
if fs is not None:
_CSS_HASH_CACHE.move_to_end(h)
return set(fs)
return None
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _split_rules(css: str) -> list[str]:
"""Split minified CSS into individual top-level rules using brace tracking.
Each returned string is a complete rule including its braces, e.g.:
".flex{display:flex}"
"@media (min-width:640px){.sm\\:hidden{display:none}}"
"""
rules: list[str] = []
depth = 0
start = 0
i = 0
while i < len(css):
ch = css[i]
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
rules.append(css[start:i + 1])
start = i + 1
i += 1
# Trailing content (unlikely in valid CSS)
if start < len(css):
tail = css[start:].strip()
if tail:
rules.append(tail)
return rules
def _extract_selector(rule: str) -> str:
"""Extract the selector portion before the first '{' in a rule."""
brace = rule.find('{')
return rule[:brace].strip() if brace >= 0 else ""
def _is_utility_selector(sel: str) -> bool:
"""Check if a selector looks like a single utility class (.flex, .\\!p-2, etc).
Returns False for resets (*,:before,:after{...}), element selectors (html,body),
and @media / @keyframes wrappers (handled separately).
"""
if sel.startswith('@'):
return False
# Must start with a dot and be a single class
if not sel.startswith('.'):
return False
# Exclude selectors with spaces (descendant combinator, .prose :where(...))
if ' ' in sel:
return False
return True
def _css_selector_to_class(selector: str) -> str:
"""Convert a CSS selector to an HTML class name by unescaping.
.sm\\:hidden → sm:hidden
.h-\\[60vh\\] → h-[60vh]
.\\!p-2 → !p-2
.hover\\:text-stone-700:hover → hover:text-stone-700
"""
# Strip leading dot
name = selector.lstrip('.')
# Strip trailing pseudo-class/element (:hover, :focus, ::placeholder, etc.)
# But don't strip escaped colons (\:) — those are part of the class name.
# Unescaped colon = pseudo-class boundary.
# Find the first unescaped colon (not preceded by backslash)
result = []
i = 0
while i < len(name):
if name[i] == '\\' and i + 1 < len(name):
result.append(name[i + 1])
i += 2
elif name[i] == ':':
break # pseudo-class — stop here
else:
result.append(name[i])
i += 1
return "".join(result)
def _index_rules(rules: list[str]) -> None:
"""Index utility rules into _REGISTRY and _RULE_ORDER."""
order = len(_RULE_ORDER)
for rule in rules:
sel = _extract_selector(rule)
if sel.startswith('@media'):
# Responsive/container wrapper — extract inner rules
_index_media_block(rule, order)
order += 1
continue
if sel.startswith('@'):
# @keyframes, @supports, etc — skip
continue
if not sel.startswith('.'):
continue
# Compound selectors like ".prose :where(p)..." → key on root class "prose"
if ' ' in sel:
root_sel = sel.split(' ', 1)[0]
class_name = _css_selector_to_class(root_sel)
if class_name:
# Append to existing entry
_REGISTRY[class_name] = _REGISTRY.get(class_name, "") + rule
if class_name not in _RULE_ORDER:
_RULE_ORDER[class_name] = order
order += 1
continue
class_name = _css_selector_to_class(sel)
if class_name:
_REGISTRY[class_name] = _REGISTRY.get(class_name, "") + rule
if class_name not in _RULE_ORDER:
_RULE_ORDER[class_name] = order
order += 1
def _index_media_block(rule: str, base_order: int) -> None:
"""Index individual class rules inside an @media block.
For example:
@media (min-width:640px){.sm\\:hidden{display:none}.sm\\:flex{display:flex}}
Each inner rule gets stored wrapped in the @media query.
"""
# Extract the @media wrapper and inner content
first_brace = rule.find('{')
if first_brace < 0:
return
media_prefix = rule[:first_brace + 1] # "@media (min-width:640px){"
# Inner content is between first { and last }
inner = rule[first_brace + 1:]
if inner.endswith('}'):
inner = inner[:-1] # strip the closing } of @media
# Split inner content into individual rules
inner_rules = _split_rules(inner)
for i, inner_rule in enumerate(inner_rules):
sel = _extract_selector(inner_rule)
class_name = _css_selector_to_class(sel)
if class_name:
# Wrap in the @media block
_REGISTRY[class_name] = media_prefix + inner_rule + "}"
_RULE_ORDER[class_name] = base_order + i

View File

@@ -14,6 +14,34 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr from .parser import SxExpr
# ---------------------------------------------------------------------------
# Pre-computed CSS classes for inline sx built by Python helpers
# ---------------------------------------------------------------------------
# These :class strings appear in post_header_sx / post_admin_header_sx etc.
# They're static — scan once at import time so they aren't re-scanned per request.
_HELPER_CLASS_SOURCES = [
':class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"',
':class "relative nav-group"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"',
':class "!bg-stone-500 !text-white"',
':class "fa fa-cog"',
':class "fa fa-shield-halved"',
':class "text-white"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"',
]
def _scan_helper_classes() -> frozenset[str]:
"""Scan the static class strings from helper functions once."""
from .css_registry import scan_classes_from_sx
combined = " ".join(_HELPER_CLASS_SOURCES)
return frozenset(scan_classes_from_sx(combined))
HELPER_CSS_CLASSES: frozenset[str] = _scan_helper_classes()
def call_url(ctx: dict, key: str, path: str = "/") -> str: def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url).""" """Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key) fn = ctx.get(key)
@@ -354,16 +382,57 @@ def sx_response(source_or_component: str, status: int = 200,
logging.getLogger("sx").error("sx_response parse error: %s\nSource (first 500): %s", _e, source[:500]) logging.getLogger("sx").error("sx_response parse error: %s\nSource (first 500): %s", _e, source[:500])
# For SX requests, prepend missing component definitions # For SX requests, prepend missing component definitions
comp_defs = ""
if request.headers.get("SX-Request"): if request.headers.get("SX-Request"):
comp_defs = components_for_request() comp_defs = components_for_request()
if comp_defs: if comp_defs:
body = (f'<script type="text/sx" data-components>' body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}') f'{comp_defs}</script>\n{body}')
# On-demand CSS: scan source for classes, send only new rules
from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded, lookup_css_hash, store_css_hash
from .jinja_bridge import _COMPONENT_ENV
from .types import Component as _Component
new_classes: set[str] = set()
cumulative_classes: set[str] = set()
if registry_loaded():
new_classes = scan_classes_from_sx(source)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
new_classes.update(HELPER_CSS_CLASSES)
if comp_defs:
# Use pre-computed classes for components being sent
for key, val in _COMPONENT_ENV.items():
if isinstance(val, _Component) and val.css_classes:
new_classes.update(val.css_classes)
# Resolve known classes from SX-Css header (hash or full list)
known_classes: set[str] = set()
known_raw = request.headers.get("SX-Css", "")
if known_raw:
if len(known_raw) <= 16:
# Treat as hash
looked_up = lookup_css_hash(known_raw)
if looked_up is not None:
known_classes = looked_up
else:
# Cache miss — send all classes (safe fallback)
known_classes = set()
else:
known_classes = set(known_raw.split(","))
cumulative_classes = known_classes | new_classes
new_classes -= known_classes
if new_classes:
new_rules = lookup_rules(new_classes)
if new_rules:
body = f'<style data-sx-css>{new_rules}</style>\n{body}'
resp = Response(body, status=status, content_type="text/sx") resp = Response(body, status=status, content_type="text/sx")
resp.headers["X-SX-Body-Len"] = str(len(body)) if new_classes:
resp.headers["X-SX-Source-Len"] = str(len(source)) resp.headers["SX-Css-Add"] = ",".join(sorted(new_classes))
resp.headers["X-SX-Has-Defs"] = "1" if "<script" in body else "0" if cumulative_classes:
resp.headers["SX-Css-Hash"] = store_css_hash(cumulative_classes)
if headers: if headers:
for k, v in headers.items(): for k, v in headers.items():
resp.headers[k] = v resp.headers[k] = v
@@ -386,14 +455,9 @@ _SX_PAGE_TEMPLATE = """\
<title>{title}</title> <title>{title}</title>
{meta_html} {meta_html}
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style> <style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/basics.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/cards.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/blog-content.css">
<meta name="csrf-token" content="{csrf}"> <meta name="csrf-token" content="{csrf}">
<script src="https://cdn.tailwindcss.com?plugins=typography"></script> <style id="sx-css">{sx_css}</style>
<link rel="stylesheet" href="{asset_url}/fontawesome/css/all.min.css"> <meta name="sx-css-classes" content="{sx_css_classes}">
<link rel="stylesheet" href="{asset_url}/fontawesome/css/v4-shims.min.css">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet">
<script src="https://unpkg.com/prismjs/prism.js"></script> <script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script> <script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script> <script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
@@ -418,7 +482,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<body class="bg-stone-50 text-stone-900"> <body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components>{component_defs}</script> <script type="text/sx" data-components>{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script> <script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js?v=20260301b"></script> <script src="{asset_url}/scripts/sx.js?v=20260301c"></script>
<script src="{asset_url}/scripts/body.js"></script> <script src="{asset_url}/scripts/body.js"></script>
</body> </body>
</html>""" </html>"""
@@ -429,9 +493,13 @@ def sx_page(ctx: dict, page_sx: str, *,
"""Return a minimal HTML shell that boots the page from sx source. """Return a minimal HTML shell that boots the page from sx source.
The browser loads component definitions and page sx, then sx.js The browser loads component definitions and page sx, then sx.js
renders everything client-side. renders everything client-side. CSS rules are scanned from the sx
source and component defs, then injected as a <style> block.
""" """
from .jinja_bridge import client_components_tag from .jinja_bridge import client_components_tag, _COMPONENT_ENV
from .css_registry import scan_classes_from_sx, lookup_rules, get_preamble, registry_loaded, store_css_hash
from .types import Component
components_tag = client_components_tag() components_tag = client_components_tag()
# Extract just the inner source from the <script> tag # Extract just the inner source from the <script> tag
component_defs = "" component_defs = ""
@@ -442,6 +510,27 @@ def sx_page(ctx: dict, page_sx: str, *,
if start > 0 and end > start: if start > 0 and end > start:
component_defs = components_tag[start:end] component_defs = components_tag[start:end]
# Scan for CSS classes — use pre-computed sets for components, scan page sx at request time
sx_css = ""
sx_css_classes = ""
sx_css_hash = ""
if registry_loaded():
# Union pre-computed component classes instead of re-scanning source
classes: set[str] = set()
for val in _COMPONENT_ENV.values():
if isinstance(val, Component) and val.css_classes:
classes.update(val.css_classes)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
classes.update(HELPER_CSS_CLASSES)
# Page sx is unique per request — scan it
classes.update(scan_classes_from_sx(page_sx))
# Always include body classes
classes.update(["bg-stone-50", "text-stone-900"])
rules = lookup_rules(classes)
sx_css = get_preamble() + rules
sx_css_hash = store_css_hash(classes)
sx_css_classes = sx_css_hash
asset_url = get_asset_url(ctx) asset_url = get_asset_url(ctx)
title = ctx.get("base_title", "Rose Ash") title = ctx.get("base_title", "Rose Ash")
csrf = _get_csrf_token() csrf = _get_csrf_token()
@@ -453,6 +542,8 @@ def sx_page(ctx: dict, page_sx: str, *,
csrf=_html_escape(csrf), csrf=_html_escape(csrf),
component_defs=component_defs, component_defs=component_defs,
page_sx=page_sx, page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,
) )

View File

@@ -24,11 +24,18 @@ rendered as HTML::
from __future__ import annotations from __future__ import annotations
import contextvars
from typing import Any from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval, _call_component from .evaluator import _eval, _call_component
# ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip.
css_class_collector: contextvars.ContextVar[set[str] | None] = contextvars.ContextVar(
"css_class_collector", default=None
)
class _RawHTML: class _RawHTML:
"""Marker for pre-rendered HTML that should not be escaped.""" """Marker for pre-rendered HTML that should not be escaped."""
@@ -455,6 +462,13 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
children.append(arg) children.append(arg)
i += 1 i += 1
# Collect CSS classes if collector is active
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
if collector is not None:
collector.update(str(class_val).split())
# Build opening tag # Build opening tag
parts = [f"<{tag}"] parts = [f"<{tag}"]
for attr_name, attr_val in attrs.items(): for attr_name, attr_val in attrs.items():

View File

@@ -113,11 +113,25 @@ def register_components(sx_source: str) -> None:
""" """
from .evaluator import _eval from .evaluator import _eval
from .parser import parse_all from .parser import parse_all
from .css_registry import scan_classes_from_sx
# Snapshot existing component names before eval
existing = set(_COMPONENT_ENV.keys())
exprs = parse_all(sx_source) exprs = parse_all(sx_source)
for expr in exprs: for expr in exprs:
_eval(expr, _COMPONENT_ENV) _eval(expr, _COMPONENT_ENV)
# Pre-scan CSS classes for newly registered components.
# Scan the full source once — components from the same file share the set.
# Slightly over-counts per component but safe and avoids re-scanning at request time.
all_classes: set[str] | None = None
for key, val in _COMPONENT_ENV.items():
if key not in existing and isinstance(val, Component):
if all_classes is None:
all_classes = scan_classes_from_sx(sx_source)
val.css_classes = set(all_classes)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# sx() — render s-expression from Jinja template # sx() — render s-expression from Jinja template
@@ -232,6 +246,12 @@ def client_components_tag(*names: str) -> str:
return f'<script type="text/sx" data-components>{source}</script>' return f'<script type="text/sx" data-components>{source}</script>'
def sx_css_all() -> str:
"""Return all CSS rules (preamble + utilities) for Jinja fallback pages."""
from .css_registry import get_all_css
return get_all_css()
def setup_sx_bridge(app: Any) -> None: def setup_sx_bridge(app: Any) -> None:
"""Register s-expression helpers with a Quart app's Jinja environment. """Register s-expression helpers with a Quart app's Jinja environment.
@@ -243,7 +263,9 @@ def setup_sx_bridge(app: Any) -> None:
This registers: This registers:
- ``sx(source, **kwargs)`` — sync render (components, pure HTML) - ``sx(source, **kwargs)`` — sync render (components, pure HTML)
- ``sx_async(source, **kwargs)`` — async render (with I/O resolution) - ``sx_async(source, **kwargs)`` — async render (with I/O resolution)
- ``sx_css_all()`` — full CSS dump for non-sx pages
""" """
app.jinja_env.globals["sx"] = sx app.jinja_env.globals["sx"] = sx
app.jinja_env.globals["render"] = render app.jinja_env.globals["render"] = render
app.jinja_env.globals["sx_async"] = sx_async app.jinja_env.globals["sx_async"] = sx_async
app.jinja_env.globals["sx_css_all"] = sx_css_all

View File

@@ -82,6 +82,7 @@
(div :class "block md:hidden text-md font-bold" (div :class "block md:hidden text-md font-bold"
(when auth-menu auth-menu)))) (when auth-menu auth-menu))))
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon (defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
hx-select nav child-id child oob external) hx-select nav child-id child oob external)
(let* ((c (or colour "sky")) (let* ((c (or colour "sky"))

View File

@@ -237,7 +237,7 @@ class TestBaseShell:
assert "<html" in html assert "<html" in html
assert "<title>Test</title>" in html assert "<title>Test</title>" in html
assert "<p>Hello</p>" in html assert "<p>Hello</p>" in html
assert "tailwindcss" in html assert "tw.css" in html
class TestErrorPage: class TestErrorPage:

View File

@@ -143,6 +143,7 @@ class Component:
has_children: bool # True if &rest children declared has_children: bool # True if &rest children declared
body: Any # unevaluated s-expression body body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict) closure: dict[str, Any] = field(default_factory=dict)
css_classes: set[str] = field(default_factory=set) # pre-scanned :class values
def __repr__(self): def __repr__(self):
return f"<Component ~{self.name}({', '.join(self.params)})>" return f"<Component ~{self.name}({', '.join(self.params)})>"