Coverage for src / lexigram / ui / layouts / mixins.py: 100%

55 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1"""Manager classes for layout CSS and JavaScript assets. 

2 

3Provides composition-based asset management for layouts. 

4""" 

5 

6from __future__ import annotations 

7 

8from markupsafe import escape 

9 

10 

11class CSSManager: 

12 """Manages CSS asset injection for layout rendering.""" 

13 

14 def __init__(self) -> None: 

15 """Initialize CSS collections.""" 

16 self._css_files: list[tuple[str, dict[str, str]]] = [] 

17 self._inline_styles: list[str] = [] 

18 

19 def add_css(self, href: str, **attrs: str) -> None: 

20 """Add a CSS file link. 

21 

22 Args: 

23 href: URL to CSS file 

24 **attrs: Additional attributes (media, crossorigin, etc.) 

25 """ 

26 self._css_files.append((href, attrs)) 

27 

28 def add_inline_style(self, css: str) -> None: 

29 """Add inline CSS. 

30 

31 Args: 

32 css: CSS rules 

33 """ 

34 self._inline_styles.append(css) 

35 

36 def render_css(self) -> str: 

37 """Render all CSS as HTML. 

38 

39 Returns: 

40 HTML string with link and style tags 

41 """ 

42 parts: list[str] = [] 

43 

44 # External CSS 

45 for href, attrs in self._css_files: 

46 attr_str = " ".join(f'{k}="{escape(v)}"' for k, v in attrs.items()) 

47 if attr_str: 

48 parts.append( 

49 f'<link rel="stylesheet" href="{escape(href)}" {attr_str}>', 

50 ) 

51 else: 

52 parts.append(f'<link rel="stylesheet" href="{escape(href)}">') 

53 

54 # Inline styles 

55 if self._inline_styles: 

56 parts.append("<style>") 

57 parts.extend(self._inline_styles) 

58 parts.append("</style>") 

59 

60 return "\n".join(parts) 

61 

62 

63class JSManager: 

64 """Manages JavaScript asset injection for layout rendering.""" 

65 

66 def __init__(self) -> None: 

67 """Initialize JS collections.""" 

68 self._js_files: list[tuple[str, dict[str, str]]] = [] 

69 self._inline_scripts: list[str] = [] 

70 self._deferred_scripts: list[str] = [] 

71 

72 def add_js( 

73 self, 

74 src: str, 

75 defer: bool = False, 

76 async_: bool = False, 

77 **attrs: str, 

78 ) -> None: 

79 """Add a JavaScript file. 

80 

81 Args: 

82 src: URL to JS file 

83 defer: Add defer attribute 

84 async_: Add async attribute 

85 **attrs: Additional attributes 

86 """ 

87 if defer: 

88 attrs["defer"] = "" 

89 if async_: 

90 attrs["async"] = "" 

91 self._js_files.append((src, attrs)) 

92 

93 def add_inline_script(self, script: str, defer: bool = False) -> None: 

94 """Add inline JavaScript. 

95 

96 Args: 

97 script: JavaScript code 

98 defer: If True, render at end of body 

99 """ 

100 if defer: 

101 self._deferred_scripts.append(script) 

102 else: 

103 self._inline_scripts.append(script) 

104 

105 def render_js_head(self) -> str: 

106 """Render JS for head section. 

107 

108 Returns: 

109 HTML string with script tags 

110 """ 

111 parts: list[str] = [] 

112 

113 # External JS 

114 for src, attrs in self._js_files: 

115 attr_str = " ".join( 

116 f'{k}="{escape(v)}"' if v else k for k, v in attrs.items() 

117 ) 

118 if attr_str: 

119 parts.append(f'<script src="{escape(src)}" {attr_str}></script>') 

120 else: 

121 parts.append(f'<script src="{escape(src)}"></script>') 

122 

123 # Inline scripts (non-deferred) 

124 for script in self._inline_scripts: 

125 parts.append(f"<script>{script}</script>") 

126 

127 return "\n".join(parts) 

128 

129 def render_js_body_end(self) -> str: 

130 """Render deferred JS for end of body. 

131 

132 Returns: 

133 HTML string with script tags 

134 """ 

135 if not self._deferred_scripts: 

136 return "" 

137 

138 parts = ["<script>"] 

139 parts.extend(self._deferred_scripts) 

140 parts.append("</script>") 

141 return "\n".join(parts) 

142 

143 

144__all__ = ["CSSManager", "JSManager"]