Add on-demand CSS: registry, pre-computed component classes, header compression
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 42s
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:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
40
shared/static/styles/tailwind.config.js
Normal file
40
shared/static/styles/tailwind.config.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
safelist: [
|
||||||
|
// ~menu-row-sx builds bg-{colour}-{shade} dynamically via (str ...)
|
||||||
|
// Levels 1–4 produce shades 400–100 (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
327
shared/sx/css_registry.py
Normal 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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)})>"
|
||||||
|
|||||||
Reference in New Issue
Block a user