Commit | Line | Data |
---|---|---|
fbe18cc0 FM |
1 | <?php |
2 | ||
3 | namespace Sabberworm\CSS\CSSList; | |
4 | ||
376eb156 MM |
5 | use Sabberworm\CSS\Comment\Commentable; |
6 | use Sabberworm\CSS\Parsing\ParserState; | |
7 | use Sabberworm\CSS\Parsing\SourceException; | |
8 | use Sabberworm\CSS\Parsing\UnexpectedTokenException; | |
9 | use Sabberworm\CSS\Property\AtRule; | |
10 | use Sabberworm\CSS\Property\Charset; | |
11 | use Sabberworm\CSS\Property\CSSNamespace; | |
12 | use Sabberworm\CSS\Property\Import; | |
13 | use Sabberworm\CSS\Property\Selector; | |
fbe18cc0 | 14 | use Sabberworm\CSS\Renderable; |
376eb156 | 15 | use Sabberworm\CSS\RuleSet\AtRuleSet; |
fbe18cc0 FM |
16 | use Sabberworm\CSS\RuleSet\DeclarationBlock; |
17 | use Sabberworm\CSS\RuleSet\RuleSet; | |
376eb156 MM |
18 | use Sabberworm\CSS\Value\CSSString; |
19 | use Sabberworm\CSS\Value\URL; | |
20 | use Sabberworm\CSS\Value\Value; | |
fbe18cc0 FM |
21 | |
22 | /** | |
23 | * A CSSList is the most generic container available. Its contents include RuleSet as well as other CSSList objects. | |
24 | * Also, it may contain Import and Charset objects stemming from @-rules. | |
25 | */ | |
26 | abstract class CSSList implements Renderable, Commentable { | |
27 | ||
28 | protected $aComments; | |
29 | protected $aContents; | |
30 | protected $iLineNo; | |
31 | ||
32 | public function __construct($iLineNo = 0) { | |
33 | $this->aComments = array(); | |
34 | $this->aContents = array(); | |
35 | $this->iLineNo = $iLineNo; | |
36 | } | |
37 | ||
376eb156 MM |
38 | public static function parseList(ParserState $oParserState, CSSList $oList) { |
39 | $bIsRoot = $oList instanceof Document; | |
40 | if(is_string($oParserState)) { | |
41 | $oParserState = new ParserState($oParserState); | |
42 | } | |
43 | $bLenientParsing = $oParserState->getSettings()->bLenientParsing; | |
44 | while(!$oParserState->isEnd()) { | |
45 | $comments = $oParserState->consumeWhiteSpace(); | |
46 | $oListItem = null; | |
47 | if($bLenientParsing) { | |
48 | try { | |
49 | $oListItem = self::parseListItem($oParserState, $oList); | |
50 | } catch (UnexpectedTokenException $e) { | |
51 | $oListItem = false; | |
52 | } | |
53 | } else { | |
54 | $oListItem = self::parseListItem($oParserState, $oList); | |
55 | } | |
56 | if($oListItem === null) { | |
57 | // List parsing finished | |
58 | return; | |
59 | } | |
60 | if($oListItem) { | |
61 | $oListItem->setComments($comments); | |
62 | $oList->append($oListItem); | |
63 | } | |
64 | $oParserState->consumeWhiteSpace(); | |
65 | } | |
66 | if(!$bIsRoot && !$bLenientParsing) { | |
67 | throw new SourceException("Unexpected end of document", $oParserState->currentLine()); | |
68 | } | |
69 | } | |
70 | ||
71 | private static function parseListItem(ParserState $oParserState, CSSList $oList) { | |
72 | $bIsRoot = $oList instanceof Document; | |
73 | if ($oParserState->comes('@')) { | |
74 | $oAtRule = self::parseAtRule($oParserState); | |
75 | if($oAtRule instanceof Charset) { | |
76 | if(!$bIsRoot) { | |
77 | throw new UnexpectedTokenException('@charset may only occur in root document', '', 'custom', $oParserState->currentLine()); | |
78 | } | |
79 | if(count($oList->getContents()) > 0) { | |
80 | throw new UnexpectedTokenException('@charset must be the first parseable token in a document', '', 'custom', $oParserState->currentLine()); | |
81 | } | |
82 | $oParserState->setCharset($oAtRule->getCharset()->getString()); | |
83 | } | |
84 | return $oAtRule; | |
85 | } else if ($oParserState->comes('}')) { | |
86 | $oParserState->consume('}'); | |
87 | if ($bIsRoot) { | |
88 | if ($oParserState->getSettings()->bLenientParsing) { | |
89 | while ($oParserState->comes('}')) $oParserState->consume('}'); | |
90 | return DeclarationBlock::parse($oParserState); | |
91 | } else { | |
92 | throw new SourceException("Unopened {", $oParserState->currentLine()); | |
93 | } | |
94 | } else { | |
95 | return null; | |
96 | } | |
97 | } else { | |
98 | return DeclarationBlock::parse($oParserState); | |
99 | } | |
100 | } | |
101 | ||
102 | private static function parseAtRule(ParserState $oParserState) { | |
103 | $oParserState->consume('@'); | |
104 | $sIdentifier = $oParserState->parseIdentifier(); | |
105 | $iIdentifierLineNum = $oParserState->currentLine(); | |
106 | $oParserState->consumeWhiteSpace(); | |
107 | if ($sIdentifier === 'import') { | |
108 | $oLocation = URL::parse($oParserState); | |
109 | $oParserState->consumeWhiteSpace(); | |
110 | $sMediaQuery = null; | |
111 | if (!$oParserState->comes(';')) { | |
112 | $sMediaQuery = $oParserState->consumeUntil(';'); | |
113 | } | |
114 | $oParserState->consume(';'); | |
115 | return new Import($oLocation, $sMediaQuery, $iIdentifierLineNum); | |
116 | } else if ($sIdentifier === 'charset') { | |
117 | $sCharset = CSSString::parse($oParserState); | |
118 | $oParserState->consumeWhiteSpace(); | |
119 | $oParserState->consume(';'); | |
120 | return new Charset($sCharset, $iIdentifierLineNum); | |
121 | } else if (self::identifierIs($sIdentifier, 'keyframes')) { | |
122 | $oResult = new KeyFrame($iIdentifierLineNum); | |
123 | $oResult->setVendorKeyFrame($sIdentifier); | |
124 | $oResult->setAnimationName(trim($oParserState->consumeUntil('{', false, true))); | |
125 | CSSList::parseList($oParserState, $oResult); | |
126 | return $oResult; | |
127 | } else if ($sIdentifier === 'namespace') { | |
128 | $sPrefix = null; | |
129 | $mUrl = Value::parsePrimitiveValue($oParserState); | |
130 | if (!$oParserState->comes(';')) { | |
131 | $sPrefix = $mUrl; | |
132 | $mUrl = Value::parsePrimitiveValue($oParserState); | |
133 | } | |
134 | $oParserState->consume(';'); | |
135 | if ($sPrefix !== null && !is_string($sPrefix)) { | |
136 | throw new UnexpectedTokenException('Wrong namespace prefix', $sPrefix, 'custom', $iIdentifierLineNum); | |
137 | } | |
138 | if (!($mUrl instanceof CSSString || $mUrl instanceof URL)) { | |
139 | throw new UnexpectedTokenException('Wrong namespace url of invalid type', $mUrl, 'custom', $iIdentifierLineNum); | |
140 | } | |
141 | return new CSSNamespace($mUrl, $sPrefix, $iIdentifierLineNum); | |
142 | } else { | |
143 | //Unknown other at rule (font-face or such) | |
144 | $sArgs = trim($oParserState->consumeUntil('{', false, true)); | |
145 | if (substr_count($sArgs, "(") != substr_count($sArgs, ")")) { | |
146 | if($oParserState->getSettings()->bLenientParsing) { | |
147 | return NULL; | |
148 | } else { | |
149 | throw new SourceException("Unmatched brace count in media query", $oParserState->currentLine()); | |
150 | } | |
151 | } | |
152 | $bUseRuleSet = true; | |
153 | foreach(explode('/', AtRule::BLOCK_RULES) as $sBlockRuleName) { | |
154 | if(self::identifierIs($sIdentifier, $sBlockRuleName)) { | |
155 | $bUseRuleSet = false; | |
156 | break; | |
157 | } | |
158 | } | |
159 | if($bUseRuleSet) { | |
160 | $oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum); | |
161 | RuleSet::parseRuleSet($oParserState, $oAtRule); | |
162 | } else { | |
163 | $oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum); | |
164 | CSSList::parseList($oParserState, $oAtRule); | |
165 | } | |
166 | return $oAtRule; | |
167 | } | |
168 | } | |
169 | ||
170 | /** | |
171 | * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed. We need to check for these versions too. | |
172 | */ | |
173 | private static function identifierIs($sIdentifier, $sMatch) { | |
174 | return (strcasecmp($sIdentifier, $sMatch) === 0) | |
175 | ?: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1; | |
176 | } | |
177 | ||
178 | ||
fbe18cc0 FM |
179 | /** |
180 | * @return int | |
181 | */ | |
182 | public function getLineNo() { | |
183 | return $this->iLineNo; | |
184 | } | |
185 | ||
376eb156 MM |
186 | /** |
187 | * Prepend item to list of contents. | |
188 | * | |
189 | * @param object $oItem Item. | |
190 | */ | |
191 | public function prepend($oItem) { | |
192 | array_unshift($this->aContents, $oItem); | |
193 | } | |
194 | ||
195 | /** | |
196 | * Append item to list of contents. | |
197 | * | |
198 | * @param object $oItem Item. | |
199 | */ | |
fbe18cc0 FM |
200 | public function append($oItem) { |
201 | $this->aContents[] = $oItem; | |
202 | } | |
203 | ||
515ceadd | 204 | /** |
376eb156 | 205 | * Splice the list of contents. |
515ceadd | 206 | * |
376eb156 MM |
207 | * @param int $iOffset Offset. |
208 | * @param int $iLength Length. Optional. | |
209 | * @param RuleSet[] $mReplacement Replacement. Optional. | |
515ceadd | 210 | */ |
376eb156 MM |
211 | public function splice($iOffset, $iLength = null, $mReplacement = null) { |
212 | array_splice($this->aContents, $iOffset, $iLength, $mReplacement); | |
515ceadd FM |
213 | } |
214 | ||
fbe18cc0 FM |
215 | /** |
216 | * Removes an item from the CSS list. | |
217 | * @param RuleSet|Import|Charset|CSSList $oItemToRemove May be a RuleSet (most likely a DeclarationBlock), a Import, a Charset or another CSSList (most likely a MediaQuery) | |
376eb156 | 218 | * @return bool Whether the item was removed. |
fbe18cc0 FM |
219 | */ |
220 | public function remove($oItemToRemove) { | |
221 | $iKey = array_search($oItemToRemove, $this->aContents, true); | |
222 | if ($iKey !== false) { | |
223 | unset($this->aContents[$iKey]); | |
224 | return true; | |
225 | } | |
226 | return false; | |
227 | } | |
228 | ||
376eb156 MM |
229 | /** |
230 | * Replaces an item from the CSS list. | |
231 | * @param RuleSet|Import|Charset|CSSList $oItemToRemove May be a RuleSet (most likely a DeclarationBlock), a Import, a Charset or another CSSList (most likely a MediaQuery) | |
232 | */ | |
233 | public function replace($oOldItem, $oNewItem) { | |
234 | $iKey = array_search($oOldItem, $this->aContents, true); | |
235 | if ($iKey !== false) { | |
236 | array_splice($this->aContents, $iKey, 1, $oNewItem); | |
237 | return true; | |
238 | } | |
239 | return false; | |
240 | } | |
241 | ||
fbe18cc0 FM |
242 | /** |
243 | * Set the contents. | |
244 | * @param array $aContents Objects to set as content. | |
245 | */ | |
246 | public function setContents(array $aContents) { | |
247 | $this->aContents = array(); | |
248 | foreach ($aContents as $content) { | |
249 | $this->append($content); | |
250 | } | |
251 | } | |
252 | ||
253 | /** | |
254 | * Removes a declaration block from the CSS list if it matches all given selectors. | |
255 | * @param array|string $mSelector The selectors to match. | |
256 | * @param boolean $bRemoveAll Whether to stop at the first declaration block found or remove all blocks | |
257 | */ | |
258 | public function removeDeclarationBlockBySelector($mSelector, $bRemoveAll = false) { | |
259 | if ($mSelector instanceof DeclarationBlock) { | |
260 | $mSelector = $mSelector->getSelectors(); | |
261 | } | |
262 | if (!is_array($mSelector)) { | |
263 | $mSelector = explode(',', $mSelector); | |
264 | } | |
265 | foreach ($mSelector as $iKey => &$mSel) { | |
266 | if (!($mSel instanceof Selector)) { | |
267 | $mSel = new Selector($mSel); | |
268 | } | |
269 | } | |
270 | foreach ($this->aContents as $iKey => $mItem) { | |
271 | if (!($mItem instanceof DeclarationBlock)) { | |
272 | continue; | |
273 | } | |
274 | if ($mItem->getSelectors() == $mSelector) { | |
275 | unset($this->aContents[$iKey]); | |
276 | if (!$bRemoveAll) { | |
277 | return; | |
278 | } | |
279 | } | |
280 | } | |
281 | } | |
282 | ||
283 | public function __toString() { | |
284 | return $this->render(new \Sabberworm\CSS\OutputFormat()); | |
285 | } | |
286 | ||
287 | public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { | |
288 | $sResult = ''; | |
289 | $bIsFirst = true; | |
290 | $oNextLevel = $oOutputFormat; | |
291 | if(!$this->isRootList()) { | |
292 | $oNextLevel = $oOutputFormat->nextLevel(); | |
293 | } | |
294 | foreach ($this->aContents as $oContent) { | |
295 | $sRendered = $oOutputFormat->safely(function() use ($oNextLevel, $oContent) { | |
296 | return $oContent->render($oNextLevel); | |
297 | }); | |
298 | if($sRendered === null) { | |
299 | continue; | |
300 | } | |
301 | if($bIsFirst) { | |
302 | $bIsFirst = false; | |
303 | $sResult .= $oNextLevel->spaceBeforeBlocks(); | |
304 | } else { | |
305 | $sResult .= $oNextLevel->spaceBetweenBlocks(); | |
306 | } | |
307 | $sResult .= $sRendered; | |
308 | } | |
309 | ||
310 | if(!$bIsFirst) { | |
311 | // Had some output | |
312 | $sResult .= $oOutputFormat->spaceAfterBlocks(); | |
313 | } | |
314 | ||
315 | return $sResult; | |
316 | } | |
317 | ||
318 | /** | |
319 | * Return true if the list can not be further outdented. Only important when rendering. | |
320 | */ | |
321 | public abstract function isRootList(); | |
322 | ||
323 | public function getContents() { | |
324 | return $this->aContents; | |
325 | } | |
326 | ||
327 | /** | |
328 | * @param array $aComments Array of comments. | |
329 | */ | |
330 | public function addComments(array $aComments) { | |
331 | $this->aComments = array_merge($this->aComments, $aComments); | |
332 | } | |
333 | ||
334 | /** | |
335 | * @return array | |
336 | */ | |
337 | public function getComments() { | |
338 | return $this->aComments; | |
339 | } | |
340 | ||
341 | /** | |
342 | * @param array $aComments Array containing Comment objects. | |
343 | */ | |
344 | public function setComments(array $aComments) { | |
345 | $this->aComments = $aComments; | |
346 | } | |
347 | ||
348 | } |