@hypergood/css-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/string.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Started with letters first because I think it looks better
2
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
3
+ const ALPHABET_LENGTH = ALPHABET.length;
4
+
5
+ function toChar63(number: number) {
6
+ return ALPHABET[number - 1];
7
+ }
8
+
9
+ /**
10
+ * Oops it's actually base 36, limited to lowercase letters and numbers for
11
+ * better compression.
12
+ */
13
+ export function toBase64ish(number: number): string {
14
+ let string = "";
15
+ let _number = number;
16
+
17
+ while (_number > 0) {
18
+ string = string + toChar63(_number % ALPHABET_LENGTH || ALPHABET_LENGTH);
19
+ _number = Math.floor((_number - 1) / ALPHABET_LENGTH);
20
+ }
21
+
22
+ return string;
23
+ }
24
+
25
+ /** Returns the given value converted to camel-case. */
26
+ export const toCamelCase = (value: string) =>
27
+ !/[A-Z]/.test(value)
28
+ ? value.replace(/-[^]/g, (capital) => capital[1].toUpperCase())
29
+ : value;
30
+
31
+ /** Returns the given value converted to kebab-case. */
32
+ export const toKebabCase = (value: string) =>
33
+ // ignore kebab-like values
34
+ value.includes("-")
35
+ ? value
36
+ : // replace any upper-case letter with a dash and the lower-case variant
37
+ value.replace(/[A-Z]/g, (capital) => "-" + capital.toLowerCase());
@@ -0,0 +1,171 @@
1
+ import { mergeClassNames } from "./index.js";
2
+ import { printStyleSheet } from "./print.js";
3
+ import { toBase64ish } from "./string.js";
4
+ import { AnimationRules, StyleRule } from "./style-rule.js";
5
+
6
+ function dedupeRules(rules: StyleRule[]) {
7
+ return rules.filter((r, i, all) => {
8
+ const firstIndex = all.findIndex(
9
+ (r2) => r2.property === r.property && r2.value === r.value,
10
+ );
11
+ if (firstIndex === i) return true;
12
+ return false;
13
+ });
14
+ }
15
+
16
+ function dedupeAnimations(rules: AnimationRules[]) {
17
+ return rules.filter((r, i, all) => {
18
+ const firstIndex = all.findIndex(
19
+ (r2) => JSON.stringify(r2) === JSON.stringify(r),
20
+ );
21
+ if (firstIndex === i) return true;
22
+ return false;
23
+ });
24
+ }
25
+
26
+ function createAnimationNameCache() {
27
+ let cacheIndex = 1;
28
+ let keyframesHashToAnimationName = new Map<string, string>();
29
+
30
+ function createAnimationName() {
31
+ return "x" + toBase64ish(cacheIndex++);
32
+ }
33
+
34
+ function getAnimationName(rule: AnimationRules) {
35
+ const propertyHash = JSON.stringify(rule);
36
+
37
+ let propertySegment = keyframesHashToAnimationName.get(propertyHash);
38
+ if (!propertySegment) {
39
+ propertySegment = createAnimationName();
40
+ keyframesHashToAnimationName.set(propertyHash, propertySegment);
41
+ }
42
+
43
+ return propertySegment;
44
+ }
45
+
46
+ return { getAnimationName };
47
+ }
48
+
49
+ function createPropertyClassSegmentCache() {
50
+ let propertyCacheIndex = 1;
51
+ let propertyHashToClassSegment = new Map<string, string>();
52
+
53
+ function createPropertyClassSegment() {
54
+ return toBase64ish(propertyCacheIndex++);
55
+ }
56
+
57
+ function getPropertyClassSegment(rule: StyleRule) {
58
+ let { selector, property, atRules } = rule;
59
+ const propertyHash = [selector, property, ...atRules].join(" $ ");
60
+
61
+ let propertySegment = propertyHashToClassSegment.get(propertyHash);
62
+ if (!propertySegment) {
63
+ propertySegment = createPropertyClassSegment();
64
+ propertyHashToClassSegment.set(propertyHash, propertySegment);
65
+ }
66
+
67
+ return propertySegment;
68
+ }
69
+
70
+ return { getPropertyClassSegment };
71
+ }
72
+
73
+ function createValueClassSegmentCache() {
74
+ let valueCacheIndices = new Map<string, number>();
75
+ function getValueCacheIndex(propertyHash: string) {
76
+ return valueCacheIndices.get(propertyHash) || 1;
77
+ }
78
+ function incrementValueCacheIndex(propertyHash: string) {
79
+ valueCacheIndices.set(propertyHash, getValueCacheIndex(propertyHash) + 1);
80
+ }
81
+ let valueClassSegmentCache: {
82
+ [propertyClassSegment: string]: {
83
+ [valueHash: string]: string;
84
+ };
85
+ } = {};
86
+ function getValueClassSegmentFromCache(
87
+ propertyHash: string,
88
+ valueHash: string,
89
+ ): string | undefined {
90
+ return valueClassSegmentCache[propertyHash]?.[valueHash];
91
+ }
92
+ function setValueClassSegment(
93
+ propertyHash: string,
94
+ valueHash: string,
95
+ valueClassSegment: string,
96
+ ) {
97
+ if (!valueClassSegmentCache[propertyHash]) {
98
+ valueClassSegmentCache[propertyHash] = {};
99
+ }
100
+ valueClassSegmentCache[propertyHash][valueHash] = valueClassSegment;
101
+ }
102
+ function createValueClassSegment(propertyHash: string) {
103
+ const idx = getValueCacheIndex(propertyHash);
104
+ const valueClassSegment = toBase64ish(idx);
105
+ incrementValueCacheIndex(propertyHash);
106
+ return valueClassSegment;
107
+ }
108
+
109
+ function getValueClassSegment(rule: StyleRule, propertyHash: string) {
110
+ let { value } = rule;
111
+
112
+ const valueHash = value;
113
+ let valueClassSegment = getValueClassSegmentFromCache(
114
+ propertyHash,
115
+ valueHash,
116
+ );
117
+ if (!valueClassSegment) {
118
+ valueClassSegment = createValueClassSegment(propertyHash);
119
+ setValueClassSegment(propertyHash, valueHash, valueClassSegment);
120
+ }
121
+ return valueClassSegment;
122
+ }
123
+
124
+ return { getValueClassSegment };
125
+ }
126
+
127
+ export function createStyleCache() {
128
+ let rules: StyleRule[] = [];
129
+ let animations: AnimationRules[] = [];
130
+
131
+ const { getPropertyClassSegment } = createPropertyClassSegmentCache();
132
+ const { getValueClassSegment } = createValueClassSegmentCache();
133
+ const { getAnimationName } = createAnimationNameCache();
134
+
135
+ function styleRuleToClassName(rule: StyleRule) {
136
+ let propertyClassSegment = getPropertyClassSegment(rule);
137
+ let valueClassSegment = getValueClassSegment(rule, propertyClassSegment);
138
+
139
+ const className = "x" + propertyClassSegment + "-" + valueClassSegment;
140
+
141
+ return className;
142
+ }
143
+
144
+ return {
145
+ getLength: () => rules.length,
146
+ addRules: (r: StyleRule[]) => {
147
+ rules.push(...r);
148
+ rules = dedupeRules(rules);
149
+ },
150
+ getClassNames: (r: StyleRule[]) => {
151
+ rules.push(...r);
152
+ rules = dedupeRules(rules);
153
+ return mergeClassNames(r.map(styleRuleToClassName));
154
+ },
155
+ getAnimationName: (keyframe: AnimationRules) => {
156
+ animations.push(keyframe);
157
+ animations = dedupeAnimations(animations);
158
+ return getAnimationName(keyframe);
159
+ },
160
+ processStyleRules: () => {
161
+ return printStyleSheet({
162
+ animations,
163
+ getAnimationName,
164
+ styleRules: rules,
165
+ styleRuleToClassName,
166
+ });
167
+ },
168
+ };
169
+ }
170
+
171
+ export type StyleCache = ReturnType<typeof createStyleCache>;
@@ -0,0 +1,181 @@
1
+ import { toKebabCase } from "./string.js";
2
+ import { StyleRule } from "./style-rule.js";
3
+
4
+ export type StyleObject = {
5
+ [key in string]: string | number | StyleObject;
6
+ };
7
+
8
+ export type KeyframesObject = {
9
+ [key in string]: StyleObject;
10
+ };
11
+
12
+ function joinSingleSelector(a: string, b: string) {
13
+ if (!b.includes("&")) {
14
+ if (b.includes(" ")) {
15
+ b = `& ${b}`;
16
+ } else {
17
+ if (/^[a-zA-Z]/.test(b) || b.startsWith("*")) {
18
+ b = `& ${b}`;
19
+ } else {
20
+ b = `&${b}`;
21
+ }
22
+ }
23
+ }
24
+ const selector = b.replaceAll("&", a);
25
+
26
+ return selector;
27
+ }
28
+
29
+ export function joinSelectors(a: string, bs: string): string[] {
30
+ const bsArray = bs.split(/,\s*/);
31
+ return bsArray.map((b) => joinSingleSelector(a, b.trim()));
32
+ }
33
+
34
+ export type MacroFn = (arg: any) => void;
35
+ export type Macros = Record<string, (arg: any) => StyleObject>;
36
+
37
+ export function evaluateMacros(
38
+ cssObj: StyleObject,
39
+ macros: Macros,
40
+ usedMacroKeys: string[] = [],
41
+ ) {
42
+ return Object.fromEntries(
43
+ Object.entries(cssObj).flatMap(([key, value]) => {
44
+ const macro = macros[key];
45
+ if (!macro || usedMacroKeys.includes(key)) {
46
+ return [
47
+ [
48
+ key,
49
+ typeof value === "object"
50
+ ? evaluateMacros(value, macros, usedMacroKeys)
51
+ : value,
52
+ ],
53
+ ];
54
+ }
55
+ const macrofied = macro(value);
56
+ return Object.entries(macrofied).map(([key, value]) => [
57
+ key,
58
+ typeof value === "object"
59
+ ? evaluateMacros(value, macros, [...usedMacroKeys, key])
60
+ : value,
61
+ ]);
62
+ }),
63
+ );
64
+ }
65
+
66
+ export type Media = Record<string, string>;
67
+
68
+ export function evaluateMedia(cssObj: StyleObject, media: Media) {
69
+ return Object.fromEntries(
70
+ Object.entries(cssObj).map(([key, value]) => {
71
+ value = typeof value === "object" ? evaluateMedia(value, media) : value;
72
+ if (key.startsWith("@")) {
73
+ const thisMedia = media[key.slice(1)];
74
+ if (thisMedia) {
75
+ const newKey = `@media ${thisMedia}`;
76
+ return [newKey, value];
77
+ }
78
+ }
79
+ return [key, value];
80
+ }),
81
+ );
82
+ }
83
+
84
+ export function flattenStyleObject(
85
+ cssObj: StyleObject,
86
+ selector = "&",
87
+ atRules: string[] = [],
88
+ ): StyleRule[] {
89
+ return Object.entries(cssObj).flatMap(([key, value]) => {
90
+ if (typeof value === "object") {
91
+ if (key.startsWith("@")) {
92
+ let newAtRule = key;
93
+ return flattenStyleObject(value, selector, [...atRules, newAtRule]);
94
+ }
95
+
96
+ let nextSelectors = joinSelectors(selector, key);
97
+ return nextSelectors.flatMap((nextSelector) => {
98
+ return flattenStyleObject(value as StyleObject, nextSelector, atRules);
99
+ });
100
+ }
101
+
102
+ const property = toKebabCase(key);
103
+
104
+ if (typeof value === "number") {
105
+ if (unitlessProps[property]) {
106
+ value = value.toString();
107
+ } else {
108
+ value = value + "px";
109
+ }
110
+ }
111
+
112
+ value = value.trim();
113
+ let important = false;
114
+ if (value.endsWith("!important")) {
115
+ important = true;
116
+ value = value.slice(0, value.length - "!important".length);
117
+ }
118
+
119
+ return [
120
+ {
121
+ selector,
122
+ property,
123
+ value,
124
+ atRules,
125
+ important,
126
+ },
127
+ ];
128
+ });
129
+ }
130
+
131
+ /** CSS Properties whose number values should not be auto-converted to pixels. */
132
+ export const unitlessProps: Record<string, number> = {
133
+ "animation-iteration-count": 1,
134
+ "aspect-ratio": 1,
135
+ "border-image-outset": 1,
136
+ "border-image-slice": 1,
137
+ "border-Image-width": 1,
138
+ "box-flex": 1,
139
+ "box-flex-group": 1,
140
+ "box-ordinal-group": 1,
141
+ "column-count": 1,
142
+ columns: 1,
143
+ flex: 1,
144
+ "flex-grow": 1,
145
+ "flex-positive": 1,
146
+ "flex-shrink": 1,
147
+ "flex-negative": 1,
148
+ "flex-order": 1,
149
+ "grid-row": 1,
150
+ "grid-row-end": 1,
151
+ "grid-row-span": 1,
152
+ "grid-row-start": 1,
153
+ "grid-column": 1,
154
+ "grid-column-end": 1,
155
+ "grid-column-span": 1,
156
+ "grid-column-start": 1,
157
+ "ms-grid-row": 1,
158
+ "ms-grid-row-span": 1,
159
+ "ms-grid-column": 1,
160
+ "ms-grid-column-span": 1,
161
+ "font-weight": 1,
162
+ "line-height": 1,
163
+ opacity: 1,
164
+ order: 1,
165
+ orphans: 1,
166
+ "tab-size": 1,
167
+ widows: 1,
168
+ "z-index": 1,
169
+ zoom: 1,
170
+ "-webkit-line-clamp": 1,
171
+
172
+ // SVG-related properties
173
+ "fill-opacity": 1,
174
+ "flood-opacity": 1,
175
+ "stop-opacity": 1,
176
+ "stroke-dasharray": 1,
177
+ "stroke-dashoffset": 1,
178
+ "stroke-miterlimit": 1,
179
+ "stroke-opacity": 1,
180
+ "stroke-width": 1,
181
+ };
@@ -0,0 +1,9 @@
1
+ export type StyleRule = {
2
+ atRules: string[];
3
+ selector: string;
4
+ property: string;
5
+ value: string;
6
+ important: boolean;
7
+ };
8
+
9
+ export type AnimationRules = Record<string, StyleRule[]>;
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ evaluateMacros,
4
+ evaluateMedia,
5
+ flattenStyleObject,
6
+ joinSelectors,
7
+ } from "../src/style-object.js";
8
+
9
+ describe("joinSelectors", () => {
10
+ it("should work", () => {
11
+ expect(joinSelectors("&", "div")).toStrictEqual(["& div"]);
12
+ expect(joinSelectors("&", "div, p")).toStrictEqual(["& div", "& p"]);
13
+ expect(joinSelectors("&", "*")).toStrictEqual(["& *"]);
14
+
15
+ expect(joinSelectors("&", ".foo")).toStrictEqual(["&.foo"]);
16
+ expect(joinSelectors("&", "#foo")).toStrictEqual(["&#foo"]);
17
+ expect(joinSelectors("&", "[foo]")).toStrictEqual(["&[foo]"]);
18
+ expect(joinSelectors("&", ":hover")).toStrictEqual(["&:hover"]);
19
+ expect(joinSelectors("&", "::before")).toStrictEqual(["&::before"]);
20
+
21
+ expect(joinSelectors("&", ".foo .bar")).toStrictEqual(["& .foo .bar"]);
22
+ expect(joinSelectors("&", ">li")).toStrictEqual(["&>li"]);
23
+ });
24
+ });
25
+
26
+ describe("evaluateMacros", () => {
27
+ it("should work", () => {
28
+ expect(
29
+ evaluateMacros(
30
+ {
31
+ foo: "bar",
32
+ },
33
+ {
34
+ foo: (value) => ({
35
+ baz: value,
36
+ }),
37
+ },
38
+ ),
39
+ ).toStrictEqual({
40
+ baz: "bar",
41
+ });
42
+
43
+ expect(
44
+ evaluateMacros(
45
+ {
46
+ foo2: "bar",
47
+ },
48
+ {
49
+ foo: (value) => ({
50
+ baz: value,
51
+ }),
52
+ },
53
+ ),
54
+ ).toStrictEqual({
55
+ foo2: "bar",
56
+ });
57
+
58
+ expect(
59
+ evaluateMacros(
60
+ {
61
+ fontSize: 16,
62
+ },
63
+ {
64
+ fontSize: (value) => ({
65
+ fontSize: typeof value === "number" ? value / 16 + "rem" : value,
66
+ }),
67
+ },
68
+ ),
69
+ ).toStrictEqual({
70
+ fontSize: "1rem",
71
+ });
72
+
73
+ expect(
74
+ evaluateMacros(
75
+ {
76
+ ":hover": {
77
+ foo: "bar",
78
+ },
79
+ },
80
+ {
81
+ foo: (value) => ({
82
+ baz: value,
83
+ }),
84
+ },
85
+ ),
86
+ ).toStrictEqual({
87
+ ":hover": {
88
+ baz: "bar",
89
+ },
90
+ });
91
+ });
92
+
93
+ expect(
94
+ evaluateMacros(
95
+ {
96
+ darkish: {
97
+ foo: "bar",
98
+ },
99
+ },
100
+ {
101
+ darkish: (value) => ({
102
+ "@dark": value,
103
+ }),
104
+ foo: (value) => ({
105
+ baz: value,
106
+ }),
107
+ },
108
+ ),
109
+ ).toStrictEqual({
110
+ "@dark": {
111
+ baz: "bar",
112
+ },
113
+ });
114
+ });
115
+
116
+ describe("evaluateMedia", () => {
117
+ it("should work", () => {
118
+ expect(
119
+ evaluateMedia(
120
+ {
121
+ width: 50,
122
+ "@bp1": {
123
+ width: 100,
124
+ "@bp2": {
125
+ width: 150,
126
+ },
127
+ },
128
+ },
129
+ {
130
+ bp1: "(min-width: 640px)",
131
+ bp2: "(min-width: 768px)",
132
+ bp3: "(min-width: 1024px)",
133
+ },
134
+ ),
135
+ ).toStrictEqual({
136
+ width: 50,
137
+ "@media (min-width: 640px)": {
138
+ width: 100,
139
+ "@media (min-width: 768px)": {
140
+ width: 150,
141
+ },
142
+ },
143
+ });
144
+ });
145
+ });
146
+
147
+ describe("flattenStyleObject", () => {
148
+ it("should work", () => {
149
+ expect(flattenStyleObject({})).toStrictEqual([]);
150
+ });
151
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "nodenext",
4
+ "strict": true,
5
+ "moduleResolution": "nodenext"
6
+ }
7
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Options } from "tsup";
2
+
3
+ export const tsup: Options = {
4
+ clean: true,
5
+ dts: true,
6
+ minify: false,
7
+ sourcemap: true,
8
+ splitting: false,
9
+ format: ["cjs", "esm"],
10
+ target: "node20",
11
+ external: ["fs", "css-tree"],
12
+ };