Skip to content

Commit 0893116

Browse files
authored
Merge pull request #1 from CSS-Canvas/refactor
Rewrite the HTML generator
2 parents 2a343fd + 111993b commit 0893116

29 files changed

+1333
-569
lines changed

.github/workflows/unit-tests.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Unit Tests
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
test:
11+
timeout-minutes: 30
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
unit: [
16+
cascading,
17+
comma,
18+
ignored,
19+
import,
20+
nth-child,
21+
selector
22+
]
23+
steps:
24+
- uses: actions/checkout@v3
25+
- uses: actions/setup-node@v3
26+
with:
27+
node-version: 18
28+
cache: 'npm'
29+
- name: Install dependencies
30+
run: npm ci
31+
- name: Install test server dependencies
32+
run: cd test-server && npm ci
33+
- name: Get Playwright version
34+
id: playwright-version
35+
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').dependencies['@playwright/test'].version)")" >> $GITHUB_ENV
36+
- name: Cache Playwright binaries
37+
uses: actions/cache@v3
38+
id: playwright-cache
39+
with:
40+
key: playwright-${{ env.PLAYWRIGHT_VERSION }}
41+
path: ~/.cache/ms-playwright
42+
- if: ${{ steps.playwright-cache.outputs.cache-hit != 'true' }}
43+
name: Install Playwright browsers
44+
run: npx playwright install --with-deps
45+
- if: ${{ steps.playwright-cache.outputs.cache-hit == 'true' }}
46+
name: Install Playwright dependencies
47+
run: npx playwright install-deps
48+
- name: Build
49+
run: npm run build
50+
- name: Run ${{ matrix.unit }} unit test
51+
run: npx playwright test ${{ matrix.unit }}

.npmignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
.github/
12
node_modules
23
src
34
tests
45
/test-results/
6+
/test-server/
57
/playwright-report/
68
/playwright/.cache/
79
.gitignore
810
playwright.config.ts
9-
post-build.js
1011
tsconfig.json

README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# CSS-to-HTML
22

3+
[![Unit Tests](https://github.com/CSS-Canvas/CSS-to-HTML/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/CSS-Canvas/CSS-to-HTML/actions/workflows/unit-tests.yml) ![npm](https://img.shields.io/npm/dt/css-to-html) ![npm bundle size](https://img.shields.io/bundlephobia/min/css-to-html)
4+
35
Generate HTML documents from just CSS.
46

57
```bash
@@ -66,11 +68,15 @@ Output:
6668

6769
## Options
6870

69-
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)._
70-
71-
| Option | Values | Description |
72-
| :----------- | :--------- | :---------- |
73-
| `duplicates` | `preserve` | Preserve duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button><button></button>`. |
74-
| | `remove` * | Remove duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button>`. |
75-
| `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>`. |
76-
| | `no-fill` | Don't fill. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span id="fourth"></span>`. |
71+
An options object can be passed as the second argument to `cssToHtml()` to customize the behavior of the HTML generator. _(Values marked with * are default)._
72+
73+
| Option | Values | Description |
74+
| :----------- | :------------- | :---------- |
75+
| `duplicates` | `preserve` | Preserve duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button><button></button>`. |
76+
| | `remove` * | Remove duplicate elements. Eg: <br/> `button {} button {}` <br/> Will become: <br/> `<button></button>`. |
77+
| `fill` | `fill` * | Fill the DOM with duplicate elements up to the desired location. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span></span><span></span><span></span><span id="fourth"></span>`. |
78+
| | `no-fill` | Don't fill. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span id="fourth"></span>`. |
79+
| `imports` | `include` | Fetch imported stylesheets and include them in the HTML generation process. |
80+
| | `style-only` * | Ignore `@import` rules. |
81+
| `mergeNth` | `merge` * | Elements generated from `:nth-` selectors will be merged with any similar element occupying the desired location. |
82+
| | `no-merge` | These elements will not be merged. |

package-lock.json

Lines changed: 25 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"name": "css-to-html",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "Generate HTML documents from CSS.",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
77
"type": "module",
88
"scripts": {
99
"build": "tsc",
10-
"postbuild": "node post-build.js",
10+
"test-server": "cd test-server && npm run dev",
1111
"test": "npm run build && npx playwright test"
1212
},
1313
"repository": {
@@ -32,5 +32,8 @@
3232
"homepage": "https://github.com/CSS-Canvas/CSS-to-HTML#readme",
3333
"devDependencies": {
3434
"@playwright/test": "^1.35.1"
35+
},
36+
"dependencies": {
37+
"css-selector-parser": "^2.3.2"
3538
}
3639
}

playwright.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,9 @@ export default defineConfig({
3737
use: { ...devices['Desktop Safari'] },
3838
},
3939
],
40+
webServer: {
41+
command: 'npm run test-server',
42+
port: 5173,
43+
reuseExistingServer: false
44+
}
4045
});

post-build.js

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/Descriptor.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { AstRule } from 'css-selector-parser';
2+
import { replaceTextNode } from './Utility.js';
3+
4+
/**
5+
* Valid positioning pseudo classes.
6+
*/
7+
const positioningPseudoClasses = [
8+
'first-child',
9+
'nth-child',
10+
'nth-last-child',
11+
'last-child',
12+
'first-of-type',
13+
'nth-of-type',
14+
'nth-last-of-type',
15+
'last-of-type'
16+
];
17+
18+
/**
19+
* Parse the position formula of `nth` pseudo classes.
20+
*
21+
* Position formulae are expressed as `an + b`, where `a` and `b` are either `0` or positive integers.
22+
* @param a
23+
* @param b
24+
* @returns A positive integer representing the desired position of the selector, or `false` if the position is invalid.
25+
*/
26+
function parsePositionFormula (a: number, b: number): number | false {
27+
// Invalid.
28+
if (a < 0 || b < 0) {
29+
return false;
30+
}
31+
// Invalid.
32+
if (!Number.isInteger(a) || !Number.isInteger(b)) {
33+
return false;
34+
}
35+
// Valid, return `b`.
36+
if (a === 0) {
37+
return b;
38+
}
39+
return false;
40+
// TODO: Add a case for when both `a` and `b` are positive.
41+
}
42+
43+
/**
44+
* Describes an element based on pieces of a selector.
45+
*/
46+
export class Descriptor {
47+
public rule;
48+
public element;
49+
public combinator = '';
50+
public position = {
51+
explicit: false,
52+
from: 'end' as 'start' | 'end',
53+
index: 1,
54+
type: 'child' as 'child' | 'type'
55+
};
56+
public invalid = false;
57+
private rawContent = '';
58+
59+
constructor (rule: AstRule, content?: string) {
60+
this.rule = rule;
61+
62+
// Create the element.
63+
let tag = 'div';
64+
if (rule.tag?.type === 'TagName') {
65+
tag = rule.tag.name;
66+
} else if (rule.tag?.type === 'WildcardTag') {
67+
this.invalid = true;
68+
}
69+
this.element = document.createElement(tag);
70+
71+
// Check for pseudo elements.
72+
if (rule.pseudoElement) {
73+
this.invalid = true
74+
}
75+
76+
if (this.invalid) {
77+
return;
78+
}
79+
80+
// Set the ids.
81+
if (rule.ids) {
82+
this.element.id = rule.ids.join(' ');
83+
}
84+
85+
// Set the classes.
86+
if (rule.classNames) {
87+
this.element.className = rule.classNames.join(' ');
88+
}
89+
90+
// Set the attributes.
91+
if (rule.attributes) {
92+
for (const attribute of rule.attributes) {
93+
let value = '';
94+
if (attribute.value?.type === 'String') {
95+
value = attribute.value.value;
96+
}
97+
this.element.setAttribute(attribute.name, value);
98+
}
99+
}
100+
101+
// Set the content.
102+
if (content) {
103+
this.content = content;
104+
}
105+
106+
// Set the combinator.
107+
this.combinator = rule.combinator ?? ' ';
108+
109+
// Set the position.
110+
if (rule.pseudoClasses && rule.pseudoClasses.length > 0) {
111+
const pseudoClass = rule.pseudoClasses[0];
112+
this.invalid = this.invalid || rule.pseudoClasses.length > 1 || !positioningPseudoClasses.includes(pseudoClass.name);
113+
if (this.invalid) return;
114+
this.position.explicit = true;
115+
this.position.from = pseudoClass.name.includes('last') ? 'end' : 'start';
116+
this.position.type = pseudoClass.name.includes('type') ? 'type' : 'child';
117+
if (pseudoClass.name.includes('nth')) {
118+
const position = pseudoClass.argument?.type === 'Formula' && parsePositionFormula(pseudoClass.argument.a, pseudoClass.argument.b);
119+
if (position) {
120+
this.position.index = position;
121+
} else {
122+
this.invalid = true;
123+
return;
124+
}
125+
}
126+
}
127+
}
128+
129+
/**
130+
* The content of the element.
131+
*/
132+
public get content (): string {
133+
return this.rawContent;
134+
}
135+
136+
public set content (value: string) {
137+
this.rawContent = value;
138+
// Strip any quote marks from around the content string.
139+
if (/(?:'|")/.test(value.charAt(0)) && /(?:'|")/.test(value.charAt(value.length - 1))) {
140+
value = value.substring(1, value.length - 1);
141+
}
142+
// Place the content in the `href` property of anchor elements.
143+
if (this.element instanceof HTMLAnchorElement) {
144+
this.element.href = value;
145+
}
146+
// Place the content in the `src` property of audio, iframe, image, and video elements.
147+
else if (
148+
this.element instanceof HTMLAudioElement
149+
|| this.element instanceof HTMLIFrameElement
150+
|| this.element instanceof HTMLImageElement
151+
|| this.element instanceof HTMLVideoElement
152+
) {
153+
this.element.src = value;
154+
}
155+
// Place the content in the `placeholder` property of input and textarea elements.
156+
else if (
157+
this.element instanceof HTMLInputElement
158+
|| this.element instanceof HTMLTextAreaElement
159+
) {
160+
this.element.placeholder = value;
161+
}
162+
// Use the content as inner-text and place it in the `value` property of option elements.
163+
else if (this.element instanceof HTMLOptionElement) {
164+
replaceTextNode(this.element, value);
165+
this.element.value = value;
166+
}
167+
// Place the content in the `value` property of select elements.
168+
else if (this.element instanceof HTMLSelectElement) {
169+
this.element.value = value;
170+
}
171+
// Use the content as inner-text for all other elements.
172+
else {
173+
replaceTextNode(this.element, value);
174+
}
175+
}
176+
177+
/**
178+
* A selector string suitable for selecting similar sibling elements.
179+
*/
180+
public get siblingSelector (): string {
181+
let selector = ':scope > ';
182+
selector += this.position.type === 'type' ? this.element.tagName : '*';
183+
selector += ':nth';
184+
selector += this.position.from === 'end' ? '-last' : '';
185+
selector += this.position.type === 'type' ? '-of-type' : '-child';
186+
selector += `(${this.position.index})`;
187+
return selector;
188+
}
189+
}

0 commit comments

Comments
 (0)