Skip to content

Commit 9cf4aa2

Browse files
committed
Add support for nth selectors
- Add support for: - `:first-child` - `:nth-child` - `:last-child` - `:first-of-type` - `:nth-of-type` - `:last-of-type` - Add `fill` option.
1 parent 6b4ebca commit 9cf4aa2

File tree

3 files changed

+147
-10
lines changed

3 files changed

+147
-10
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ Output:
6262

6363
## Options
6464

65-
An options object can be passed as the second argument to `cssToHtml()` to customize the behaviour of the HTML generator.
65+
An options object can be passed as the second argument to `cssToHtml()` to customize the behaviour of the HTML generator. _(Values marked with * are default)._
6666

6767
| Option | Values | Description |
6868
| :----------- | :--------- | :---------- |
6969
| `duplicates` | `preserve` | Preserve duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button><button></button>`. |
70-
| | `remove` | Remove duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button>`. |
70+
| | `remove` * | Remove duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button>`. |
71+
| `fill` | `fill` * | Fill the DOM with duplicate elements up to the desired level. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span></span><span></span><span></span><span id="fourth"></span>`. |
72+
| | `no-fill` | Don't fill. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span></span>`. |

src/Generator.ts

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ type Combinator = '>' | '~' | '+';
22

33
interface Options {
44
duplicates?: 'preserve' | 'remove';
5+
fill?: 'fill' | 'no-fill'
56
}
67

78
/**
@@ -11,7 +12,17 @@ interface Options {
1112
*/
1213
export function cssToHtml(css: CSSRuleList | string, options: Options = {}): HTMLBodyElement {
1314
const output = document.createElement('body');
15+
const fillerElements = [] as HTMLElement[];
16+
function isFillerElement (element: HTMLElement | Element): boolean {
17+
for (const element of fillerElements) {
18+
if (element.isSameNode(element)) {
19+
return true;
20+
}
21+
}
22+
return false;
23+
}
1424
let styleRules: CSSRuleList | undefined;
25+
1526
// Parse the CSS string into a CSSOM.
1627
if (typeof css === 'string') {
1728
const styleDocument = document.implementation.createHTMLDocument();
@@ -30,13 +41,20 @@ export function cssToHtml(css: CSSRuleList | string, options: Options = {}): HTM
3041
// Convert each rule into an HTML element, then add it to the output DOM.
3142
for (const [index, rule] of Object.entries(styleRules) as [string, (CSSStyleRule | CSSMediaRule)][]) {
3243
// Skip:
33-
// - Media rules.
34-
// - Rules including `:`.
35-
// - Rules including with `*`.
44+
// - Non-style rules.
45+
// - Rules including `*`.
46+
// - Rules including `:` (that aren't `nth-child` or `nth-of-type`).
3647
if (
37-
|| rule.selectorText.includes(':')
3848
!(rule instanceof CSSStyleRule)
3949
|| rule.selectorText.includes('*')
50+
|| (rule.selectorText.includes(':')
51+
&& !rule.selectorText.includes(':first-child')
52+
&& !rule.selectorText.includes(':nth-child')
53+
&& !rule.selectorText.includes(':last-child')
54+
&& !rule.selectorText.includes(':first-of-type')
55+
&& !rule.selectorText.includes(':nth-of-type')
56+
&& !rule.selectorText.includes(':last-of-type')
57+
)
4058
) {
4159
continue;
4260
}
@@ -56,6 +74,10 @@ export function cssToHtml(css: CSSRuleList | string, options: Options = {}): HTM
5674
classes: [''],
5775
id: '',
5876
tag: '',
77+
position: {
78+
type: '' as 'child' | 'type',
79+
index: 0
80+
},
5981
add: (character: string): void => {
6082
if (!descriptor.addressCharacter) {
6183
descriptor.tag += character;
@@ -135,14 +157,98 @@ export function cssToHtml(css: CSSRuleList | string, options: Options = {}): HTM
135157
descriptor.previousElement = newElement;
136158
}
137159

160+
function addElementToOutputWithFill (childType: 'child' | 'type', fillType: 'first' | 'nth' | 'last', fillAmount: number): void {
161+
const desiredIndex = fillAmount - 1;
162+
163+
// Create the new element.
164+
const newElement = document.createElement(descriptor.tag || 'div');
165+
// Add the classes.
166+
for (const c of descriptor.classes) {
167+
(c && newElement.classList.add(c));
168+
}
169+
// Add the ID.
170+
if (descriptor.id) {
171+
newElement.id = descriptor.id;
172+
}
173+
174+
// Get a reference to the parent element.
175+
let parentElement = undefined as HTMLElement | undefined;
176+
if (descriptor.previousElement) {
177+
if (descriptor.combinator === '>') {
178+
parentElement = descriptor.previousElement;
179+
} else {
180+
parentElement = descriptor.previousElement.parentElement ?? output;
181+
}
182+
} else {
183+
parentElement = output;
184+
}
185+
186+
if (!parentElement) {
187+
return;
188+
}
189+
190+
// Update the descriptor.
191+
descriptor.previousElement = newElement;
192+
193+
if (fillType === 'first') {
194+
parentElement.prepend(newElement);
195+
return;
196+
}
197+
198+
if (fillType === 'last') {
199+
parentElement.append(newElement);
200+
return;
201+
}
202+
203+
// Check if there is a sibling element in the desired position.
204+
const blockingSibling = parentElement.querySelector(childType === 'type' ? `${newElement.tagName}:nth-of-type(${fillAmount})` : `:nth-child(${fillAmount})`);
205+
if (blockingSibling) {
206+
parentElement.insertBefore(newElement, blockingSibling);
207+
if (isFillerElement(blockingSibling)) {
208+
blockingSibling.remove();
209+
}
210+
return;
211+
}
212+
213+
// Add the element to the DOM.
214+
parentElement.append(newElement);
215+
216+
if (options.fill !== 'no-fill') {
217+
// Count the previous siblings.
218+
let previousSiblings = 0;
219+
let previousSibling = newElement.previousElementSibling;
220+
while (previousSibling && previousSiblings < desiredIndex) {
221+
previousSibling = previousSibling?.previousElementSibling;
222+
if (childType === 'type' && previousSibling?.tagName !== newElement.tagName) {
223+
continue;
224+
}
225+
previousSiblings++;
226+
}
227+
228+
// Fill duplicate elements up to the desired position.
229+
const duplicatesRequired = desiredIndex - previousSiblings;
230+
for (let i = 0; i < duplicatesRequired; i++) {
231+
// Create the duplicate element.
232+
const duplicateElement = newElement.cloneNode() as HTMLElement;
233+
// Remove the ID.
234+
duplicateElement.removeAttribute('id');
235+
// Make a note of the duplicate element.
236+
fillerElements.push(duplicateElement);
237+
// Add the duplicate element to the DOM.
238+
parentElement.insertBefore(duplicateElement, newElement);
239+
}
240+
}
241+
}
242+
138243
// For every character in the selector, plus a stop character to indicate the end of the selector.
139-
for (const character of selector + '%') {
244+
selector += '%';
245+
for (let i = 0; i < selector.length; i++) {
246+
const character = selector[i];
140247
// The start of a new selector.
141248
if (!descriptor.previousCharacter) {
142249
if (/(?:\+|~|>)/.test(character)) {
143250
descriptor.combinator = character as Combinator;
144-
}
145-
else if (character === '.' || character === '#') {
251+
} else if (character === '.' || character === '#') {
146252
descriptor.addressCharacter = character;
147253
} else {
148254
descriptor.add(character);
@@ -167,7 +273,22 @@ export function cssToHtml(css: CSSRuleList | string, options: Options = {}): HTM
167273
descriptor.clear();
168274
descriptor.combinator = character as Combinator;
169275
}
170-
// The character none of the above.
276+
// The character is a colon.
277+
else if (character === ':') {
278+
const nthSelector = selector.substring(i + 1);
279+
const pseudoSelector =
280+
/^(first-child|first-of-type)/i.exec(nthSelector)
281+
?? /^(nth-child|nth-of-type)\(([0-9]+)\)/i.exec(nthSelector)
282+
?? /^(last-child|last-of-type)/i.exec(nthSelector);
283+
if (pseudoSelector) {
284+
const childType = pseudoSelector[1].includes('type') ? 'type' : 'child';
285+
const fillType = pseudoSelector[1].split('-')[0] as 'first' | 'nth' | 'last';
286+
addElementToOutputWithFill(childType, fillType, parseInt(pseudoSelector[2]) || 0);
287+
i += pseudoSelector[0].length;
288+
}
289+
descriptor.clear();
290+
}
291+
// The character is none of the above.
171292
else {
172293
addElementToOutput();
173294
descriptor.clear();

tests/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,24 @@ nav a#logo.icon > img {
3737
.pie .pastry.crenelations {
3838
background: radial-gradient(circle at center, orange 10%, yellow);
3939
}
40+
.pie .pastry.crenelations div#crenelation:nth-child(8) {
41+
content: 'o';
42+
}
43+
.pie .pastry.crenelations div:nth-child(3) {
44+
content: 'm';
45+
}
46+
.pie .pastry.crenelations div:nth-child(9) {
47+
content: 'p';
48+
}
4049
button {
4150
content: 'Double-click me';
4251
background-color: blue;
4352
}
53+
span:nth-child(4) {}
54+
span.first:first-child {}
55+
span:nth-of-type(3) {
56+
content: '3rd span';
57+
}
4458
`;
4559

4660
const output = cssToHtml(input);

0 commit comments

Comments
 (0)