Coverage for phml\utilities\misc\classes.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-12 14:26 -0500

1"""utilities.misc 

2 

3A collection of utilities that don't fit in with finding, selecting, testing, 

4transforming, traveling, or validating nodes. 

5""" 

6 

7from re import split, sub 

8 

9from phml.nodes import Element 

10 

11__all__ = ["classnames", "ClassList"] 

12 

13 

14def classnames( # pylint: disable=keyword-arg-before-vararg 

15 node: Element | None = None, 

16 *conditionals: str | int | list | dict[str, bool] | Element, 

17) -> str | None: 

18 """Concat a bunch of class names. Can take a str as a class, 

19 int which is cast to a str to be a class, a dict of conditional classes, 

20 and a list of all the previous conditions including itself. 

21 

22 Examples: 

23 Assume that the current class on node is `bold` 

24 * `classnames(node, 'flex')` yields `'bold flex'` 

25 * `classnames(node, 13)` yields `'bold 13'` 

26 * `classnames(node, {'shadow': True, 'border': 0})` yields `'bold shadow'` 

27 * `classnames('a', 13, {'b': True}, ['c', {'d': False}])` yields `'a b c'` 

28 

29 Args: 

30 node (Element | None): Node to apply the classes too. If no node is given 

31 then the function returns a string. 

32 

33 Returns: 

34 str: The concat string of classes after processing. 

35 """ 

36 

37 node, conditions = validate_node(node, conditionals) 

38 

39 classes = init_classes(node) 

40 

41 for condition in conditions: 

42 if isinstance(condition, str): 

43 classes.extend( 

44 [ 

45 klass 

46 for klass in split(r" ", sub(r" +", "", condition.strip())) 

47 if klass not in classes 

48 ], 

49 ) 

50 elif isinstance(condition, int) and str(condition) not in classes: 

51 classes.append(str(condition)) 

52 elif isinstance(condition, dict): 

53 for key, value in condition.items(): 

54 if value: 

55 classes.extend( 

56 [ 

57 klass 

58 for klass in split(r" ", sub(r" +", "", key.strip())) 

59 if klass not in classes 

60 ], 

61 ) 

62 elif isinstance(condition, list): 

63 classes.extend( 

64 [ 

65 klass 

66 for klass in classnames(*condition).split(" ") 

67 if klass not in classes 

68 ], 

69 ) 

70 else: 

71 raise TypeError(f"Unkown conditional statement: {condition}") 

72 

73 if node is None: 

74 return " ".join(classes) 

75 

76 node["class"] = " ".join(classes) 

77 return "" 

78 

79 

80class ClassList: 

81 """Utility class to manipulate the class list on a node. 

82 

83 Based on the hast-util-class-list: 

84 https://github.com/brechtcs/hast-util-class-list 

85 """ 

86 

87 def __init__(self, node: Element) -> None: 

88 self.node = node 

89 self._classes = str(node["class"]).split(" ") if "class" in node else [] 

90 

91 def __contains__(self, klass: str) -> bool: 

92 return klass.strip().replace(" ", "-") in self._classes 

93 

94 def toggle(self, *klasses: str): 

95 """Toggle a class in `class`.""" 

96 

97 for klass in klasses: 

98 if klass.strip().replace(" ", "-") in self._classes: 

99 self._classes.remove(klass.strip().replace(" ", "-")) 

100 else: 

101 self._classes.append(klass.strip().replace(" ", "-")) 

102 

103 self.node["class"] = self.classes 

104 

105 def add(self, *klasses: str): 

106 """Add one or more classes to `class`.""" 

107 

108 for klass in klasses: 

109 if klass not in self._classes: 

110 self._classes.append(klass.strip().replace(" ", "-")) 

111 

112 self.node["class"] = self.classes 

113 

114 def replace(self, old_class: str, new_class: str): 

115 """Replace a certain class in `class` with 

116 another class. 

117 """ 

118 

119 old_class = old_class.strip().replace(" ", "-") 

120 new_class = new_class.strip().replace(" ", "-") 

121 

122 if old_class in self._classes: 

123 idx = self._classes.index(old_class) 

124 self._classes[idx] = new_class 

125 self.node["class"] = self.classes 

126 

127 def remove(self, *klasses: str): 

128 """Remove one or more classes from `class`.""" 

129 

130 for klass in klasses: 

131 if klass in self._classes: 

132 self._classes.remove(klass) 

133 

134 if len(self._classes) == 0: 

135 self.node.attributes.pop("class", None) 

136 else: 

137 self.node["class"] = self.classes 

138 

139 @property 

140 def classes(self) -> str: 

141 """Return the formatted string of classes.""" 

142 return " ".join(self._classes) 

143 

144 

145def validate_node( 

146 node: Element | None, conditionals: tuple 

147) -> tuple[Element | None, tuple]: 

148 """Validate a node is a node and that it is an element.""" 

149 

150 if isinstance(node, (str, int, list, dict)): 

151 return None, (node, *conditionals) 

152 

153 if not isinstance(node, Element): 

154 raise TypeError("Node must be an element") 

155 

156 return node, conditionals 

157 

158 

159def init_classes(node) -> list[str]: 

160 """Get the list of classes from an element.""" 

161 if node is not None: 

162 if "class" in node.attributes: 

163 return sub(r" +", " ", node["class"]).split(" ") 

164 

165 node["class"] = "" 

166 return [] 

167 

168 return []