next-yak 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.
@@ -0,0 +1,210 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import cssloader from "../cssloader.cjs";
3
+
4
+ const loaderContext = {
5
+ resourcePath: "/some/special/path/page.tsx",
6
+ rootContext: "/some",
7
+ importModule: () => {
8
+ return {
9
+ replaces: {
10
+ queries: {
11
+ sm: "@media (min-width: 640px)",
12
+ md: "@media (min-width: 768px)",
13
+ lg: "@media (min-width: 1024px)",
14
+ xl: "@media (min-width: 1280px)",
15
+ xxl: "@media (min-width: 1536px)",
16
+ },
17
+ },
18
+ };
19
+ },
20
+ getOptions: () => ({
21
+ configPath: "/some/special/path/config",
22
+ }),
23
+ };
24
+
25
+ describe("cssloader", () => {
26
+ // snapshot
27
+ it("should return the correct value", async () => {
28
+ expect(
29
+ await cssloader.call(
30
+ loaderContext,
31
+ `
32
+ import styles from "./page.module.css";
33
+ import { css } from "next-yak";
34
+
35
+ const headline = css\`
36
+ font-size: 2rem;
37
+ font-weight: bold;
38
+ color: red;
39
+ &:hover {
40
+ color: red;
41
+ }
42
+ \`;
43
+ `
44
+ )
45
+ ).toMatchInlineSnapshot(`
46
+ ".style0 {
47
+ font-size: 2rem;
48
+ font-weight: bold;
49
+ color: red;
50
+ &:hover {
51
+ color: red;
52
+ }
53
+ }"
54
+ `);
55
+ });
56
+
57
+ it("should support nested css code", async () => {
58
+ expect(
59
+ await cssloader.call(
60
+ loaderContext,
61
+ `
62
+ import styles from "./page.module.css";
63
+ import { css } from "next-yak";
64
+
65
+ const x = Math.random();
66
+ const headline = css\`
67
+ font-size: 2rem;
68
+ font-weight: bold;
69
+ color: red;
70
+ \${x > 0.5 && css\`
71
+ color: blue;
72
+ \`}
73
+ \${x > 0.5 && css\`
74
+ color: blue;
75
+ \`}
76
+ &:hover {
77
+ color: \${x ? "red" : "blue"\};
78
+ }
79
+ \`;
80
+ `
81
+ )
82
+ ).toMatchInlineSnapshot(`
83
+ ".style0 {
84
+ font-size: 2rem;
85
+ font-weight: bold;
86
+ color: red;
87
+ }
88
+
89
+ .style1 {
90
+ color: blue;
91
+ }
92
+
93
+ .style2 {
94
+ color: blue;
95
+ }
96
+
97
+ .style0 {
98
+ &:hover {
99
+ color: var(--🦬18fi82j0);
100
+ }
101
+ }"
102
+ `);
103
+ });
104
+
105
+ it("should ignores empty chunks if they include only a comment", async () => {
106
+ expect(
107
+ await cssloader.call(
108
+ loaderContext,
109
+ `
110
+ import styles from "./page.module.css";
111
+ import { css } from "next-yak";
112
+
113
+ const x = Math.random();
114
+ const headline = css\`
115
+ /* comment */
116
+ \${x > 0.5 && css\`
117
+ color: blue;
118
+ \`}
119
+ \`;
120
+ `
121
+ )
122
+ ).toMatchInlineSnapshot(`
123
+ ".style1 {
124
+ color: blue;
125
+ }"
126
+ `);
127
+ });
128
+ });
129
+
130
+ it("should support css variables", async () => {
131
+ expect(
132
+ await cssloader.call(
133
+ loaderContext,
134
+ `
135
+ import styles from "./page.module.css";
136
+ import { css } from "next-yak";
137
+
138
+ const headline = css\`
139
+ &:hover {
140
+ color: \${x ? "red" : "blue"\};
141
+ }
142
+ \`;
143
+ `
144
+ )
145
+ ).toMatchInlineSnapshot(`
146
+ ".style0 {
147
+ &:hover {
148
+ color: var(--🦬18fi82j0);
149
+ }
150
+ }"
151
+ `);
152
+ });
153
+
154
+ it("should support css variables with spaces", async () => {
155
+ expect(
156
+ await cssloader.call(
157
+ loaderContext,
158
+ `
159
+ import styles from "./page.module.css";
160
+ import { css } from "next-yak";
161
+
162
+ const headline = css\`
163
+ transition: color \${duration} \${easing};
164
+ display: block;
165
+ \${css\`color: orange\`}
166
+ \`;
167
+ `
168
+ )
169
+ ).toMatchInlineSnapshot(`
170
+ ".style0 {
171
+ transition: color var(--🦬18fi82j0) var(--🦬18fi82j1);
172
+ display: block;
173
+ }
174
+
175
+ .style1 { color: orange }"
176
+ `);
177
+ });
178
+
179
+ it("should replace breakpoint references with actual media queries", async () => {
180
+ expect(
181
+ await cssloader.call(
182
+ loaderContext,
183
+ `
184
+ import { css } from "next-yak";
185
+ import { queries } from "@/theme";
186
+
187
+ const headline = css\`
188
+ color: blue;
189
+ \${queries.sm} {
190
+ color: red;
191
+ }
192
+ transition: color \${duration} \${easing};
193
+ display: block;
194
+ \${css\`color: orange\`}
195
+ \`;
196
+ `
197
+ )
198
+ ).toMatchInlineSnapshot(`
199
+ ".style0 {
200
+ color: blue;
201
+ @media (min-width: 640px) {
202
+ color: red;
203
+ }
204
+ transition: color var(--🦬18fi82j0) var(--🦬18fi82j1);
205
+ display: block;
206
+ }
207
+
208
+ .style1 { color: orange }"
209
+ `);
210
+ });
@@ -0,0 +1,159 @@
1
+ import tsloader from "../tsloader.cjs";
2
+ import {describe, it, expect} from "vitest";
3
+
4
+ const loaderContext = {
5
+ resourcePath: "/some/special/path/page.tsx",
6
+ rootContext: "/some",
7
+ importModule: () => {
8
+ return {
9
+ replaces: {
10
+ queries: {
11
+ sm: "@media (min-width: 640px)",
12
+ md: "@media (min-width: 768px)",
13
+ lg: "@media (min-width: 1024px)",
14
+ xl: "@media (min-width: 1280px)",
15
+ xxl: "@media (min-width: 1536px)",
16
+ },
17
+ },
18
+ };
19
+ },
20
+ getOptions: () => ({
21
+ configPath: "/some/special/path/config",
22
+ }),
23
+ };
24
+
25
+ describe("tsloader", () => {
26
+ // snapshot
27
+ it("should return the correct value", async () => {
28
+ expect(
29
+ await tsloader.call(
30
+ loaderContext,
31
+ `
32
+ "use client";
33
+ import styles from "./page.module.css";
34
+ import { css } from "next-yak";
35
+
36
+ type x = number;
37
+
38
+ const headline = css\`
39
+ font-size: 2rem;
40
+ font-weight: bold;
41
+ color: blue;
42
+ &:hover {
43
+ color: red;
44
+ }
45
+ \`;
46
+
47
+ export const Main = () => <h1 className={headline({}).className}>Hello World</h1>;
48
+ `
49
+ )
50
+ ).toMatchInlineSnapshot(`
51
+ "\\"use client\\";
52
+
53
+ import styles from \\"./page.module.css\\";
54
+ import { css } from \\"next-yak\\";
55
+ import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\";
56
+ type x = number;
57
+ const headline = css(__styleYak.style0);
58
+ export const Main = () => <h1 className={headline({}).className}>Hello World</h1>;"
59
+ `);
60
+ });
61
+ it("should support nested css code", async () => {
62
+ expect(
63
+ await tsloader.call(
64
+ loaderContext,
65
+ `
66
+ import styles from "./page.module.css";
67
+ import { css } from "next-yak";
68
+
69
+ const x = Math.random();
70
+ const headline = css\`
71
+ font-size: 2rem;
72
+ font-weight: bold;
73
+ color: red;
74
+ \${x > 0.5 && css\`
75
+ color: blue;
76
+ \`}
77
+ &:hover {
78
+ color: red;
79
+ }
80
+ \`;
81
+ `
82
+ )
83
+ ).toMatchInlineSnapshot(`
84
+ "import styles from \\"./page.module.css\\";
85
+ import { css } from \\"next-yak\\";
86
+ import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\";
87
+ const x = Math.random();
88
+ const headline = css(__styleYak.style0, x > 0.5 && css(__styleYak.style1));"
89
+ `);
90
+ });
91
+
92
+ it("should support styled api", async () => {
93
+ expect(
94
+ await tsloader.call(
95
+ loaderContext,
96
+ `
97
+ import styles from "./page.module.css";
98
+ import { styled, css } from "next-yak";
99
+
100
+ const x = Math.random();
101
+ const Button = styled.button\`
102
+ font-size: 2rem;
103
+ font-weight: bold;
104
+ color: red;
105
+ \${x > 0.5 && css\`
106
+ color: blue;
107
+ \`}
108
+ &:hover {
109
+ color: red;
110
+ }
111
+ \`;
112
+ const FancyButton = styled(Button)\`
113
+ background-color: green;
114
+ \`;
115
+ `
116
+ )
117
+ ).toMatchInlineSnapshot(`
118
+ "import styles from \\"./page.module.css\\";
119
+ import { styled, css } from \\"next-yak\\";
120
+ import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\";
121
+ const x = Math.random();
122
+ const Button = styled.button(__styleYak.style0, x > 0.5 && css(__styleYak.style1));
123
+ const FancyButton = styled(Button)(__styleYak.style2);"
124
+ `);
125
+ });
126
+ });
127
+
128
+ it("should support css variables with spaces", async () => {
129
+ expect(
130
+ await tsloader.call(
131
+ loaderContext,
132
+ `
133
+ import styles from "./page.module.css";
134
+ import { css } from "next-yak";
135
+ import { easing } from "styleguide";
136
+
137
+ const headline = css\`
138
+ transition: color \${({i}) => i * 100 + "ms"} \${easing};
139
+ display: block;
140
+ \${css\`color: orange\`}
141
+ \${css\`color: blue\`}
142
+ \`;
143
+ `
144
+ )
145
+ ).toMatchInlineSnapshot(`
146
+ "import styles from \\"./page.module.css\\";
147
+ import { css } from \\"next-yak\\";
148
+ import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\";
149
+ import { easing } from \\"styleguide\\";
150
+ const headline = css(__styleYak.style0, css(__styleYak.style1), css(__styleYak.style2), {
151
+ \\"style\\": {
152
+ \\"--\\\\uD83E\\\\uDDAC18fi82j0\\": ({
153
+ i
154
+ }) => i * 100 + \\"ms\\",
155
+ \\"--\\\\uD83E\\\\uDDAC18fi82j1\\": easing
156
+ }
157
+ });"
158
+ `);
159
+ });
@@ -0,0 +1,183 @@
1
+ /// @ts-check
2
+ const babel = require("@babel/core");
3
+ const quasiClassifier = require("./lib/quasiClassifier.cjs");
4
+ const replaceQuasiExpressionTokens = require("./lib/replaceQuasiExpressionTokens.cjs");
5
+ const loadConfigOnce = require("./lib/loadConfigOnce.cjs");
6
+ const murmurhash2_32_gc = require("./lib/hash.cjs");
7
+ const { relative, resolve } = require("path");
8
+
9
+ /**
10
+ * @param {string} source
11
+ * @this {any}
12
+ * @returns {Promise<string>}
13
+ */
14
+ module.exports = async function cssLoader(source) {
15
+ // Config for replacing tokens in css template literals
16
+ // can be based on a typescript file
17
+ const options = this.getOptions();
18
+ const config = options.configPath ? await loadConfigOnce(
19
+ async () => await this.importModule(resolve(this.rootContext, options.configPath))
20
+ ) : {};
21
+ const replaces = config.replaces || {};
22
+
23
+ // parse source with babel
24
+ const ast = babel.parseSync(source, {
25
+ filename: this.resourcePath,
26
+ plugins: [
27
+ [
28
+ "@babel/plugin-syntax-typescript",
29
+ { isTSX: this.resourcePath.endsWith(".tsx") },
30
+ ],
31
+ ],
32
+ });
33
+
34
+ if (ast === null) {
35
+ return "";
36
+ }
37
+
38
+ const { types: t } = babel;
39
+
40
+ /** @type {{css?: string, styled?: string}} */
41
+ const localVarNames = {
42
+ css: undefined,
43
+ styled: undefined,
44
+ };
45
+
46
+ let index = 0;
47
+ let varIndex = 0;
48
+ /** @type {string | null} */
49
+ let hashedFile = null;
50
+ const { rootContext, resourcePath } = this;
51
+
52
+ /**
53
+ * find all css template literals in ast
54
+ * @type {{ code: string, loc: number }[]}
55
+ */
56
+ const cssCode = [];
57
+ babel.traverse(ast, {
58
+ /**
59
+ * @param {import("@babel/traverse").NodePath<import("@babel/types").ImportDeclaration>} path
60
+ */
61
+ ImportDeclaration(path) {
62
+ const node = path.node;
63
+ if (
64
+ node.source.value !== "next-yak"
65
+ ) {
66
+ return;
67
+ }
68
+ // Process import specifiers
69
+ node.specifiers.forEach((specifier) => {
70
+ if (
71
+ !("imported" in specifier) ||
72
+ !specifier.imported ||
73
+ !t.isIdentifier(specifier.imported)
74
+ ) {
75
+ return;
76
+ }
77
+
78
+ const importSpecifier = /** @type {babel.types.Identifier} */ (
79
+ specifier.imported
80
+ );
81
+ const localSpecifier = specifier.local || importSpecifier;
82
+ if (
83
+ importSpecifier.name === "styled" ||
84
+ importSpecifier.name === "css"
85
+ ) {
86
+ localVarNames[importSpecifier.name] = localSpecifier.name;
87
+ }
88
+ });
89
+ },
90
+ /**
91
+ * @param {import("@babel/traverse").NodePath<import("@babel/types").TaggedTemplateExpression>} path
92
+ */
93
+ TaggedTemplateExpression(path) {
94
+ // Check if the tag name matches the imported 'css' or 'styled' variable
95
+ const tag = path.node.tag;
96
+
97
+ const isCssLiteral =
98
+ t.isIdentifier(tag) &&
99
+ /** @type {babel.types.Identifier} */ (tag).name === localVarNames.css;
100
+ const isStyledLiteral =
101
+ t.isMemberExpression(tag) &&
102
+ t.isIdentifier(
103
+ /** @type {babel.types.MemberExpression} */ (tag).object
104
+ ) &&
105
+ /** @type {babel.types.Identifier} */ (
106
+ /** @type {babel.types.MemberExpression} */ (tag).object
107
+ ).name === localVarNames.styled;
108
+
109
+ const isStyledCall =
110
+ t.isCallExpression(tag) &&
111
+ t.isIdentifier(
112
+ /** @type {babel.types.CallExpression} */ (tag).callee
113
+ ) &&
114
+ /** @type {babel.types.Identifier} */ (
115
+ /** @type {babel.types.CallExpression} */ (tag).callee
116
+ ).name === localVarNames.styled;
117
+
118
+ if (!isCssLiteral && !isStyledLiteral && !isStyledCall) {
119
+ return;
120
+ }
121
+
122
+ replaceQuasiExpressionTokens(path.node.quasi, replaces, t);
123
+
124
+ // Keep the same selector for all quasis belonging to the same css block
125
+ const literalSelector = `.style${index++}`;
126
+
127
+ // Replace the tagged template expression with a call to the 'styled' function
128
+ const quasis = path.node.quasi.quasis;
129
+ const quasiTypes = quasis.map((quasi) =>
130
+ quasiClassifier(quasi.value.raw)
131
+ );
132
+
133
+ for (let i = 0; i < quasis.length; i++) {
134
+ const quasi = quasis[i];
135
+ // skip empty quasis
136
+ if (quasiTypes[i].empty) {
137
+ continue;
138
+ }
139
+ let code = quasi.value.raw;
140
+ let isMerging = false;
141
+ // loop over all quasis belonging to the same css block
142
+ while (i < quasis.length - 1) {
143
+ const type = quasiTypes[i];
144
+ // expressions after a partial css are converted into css variables
145
+ if (
146
+ type.partialStart ||
147
+ type.partialEnd ||
148
+ (isMerging && type.empty)
149
+ ) {
150
+ isMerging = true;
151
+ if (!hashedFile) {
152
+ const relativePath = relative(rootContext, resourcePath);
153
+ hashedFile = murmurhash2_32_gc(relativePath);
154
+ }
155
+ // replace the expression with a css variable
156
+ code += `var(--🦬${hashedFile}${varIndex++})`;
157
+ // as we are after the css block, we need to increment i
158
+ // to get the very next quasi
159
+ i++;
160
+ code += quasis[i].value.raw;
161
+ } else if (type.empty) {
162
+ // empty quasis are also added to keep spacings
163
+ // e.g. `transition: color ${duration} ${easing};`
164
+ i++;
165
+ code += quasis[i].value.raw;
166
+ } else {
167
+ break;
168
+ }
169
+ }
170
+
171
+ cssCode.push({
172
+ code: `${literalSelector} { ${code} }`,
173
+ loc: quasi.loc?.start.line || 0,
174
+ });
175
+ }
176
+ },
177
+ });
178
+
179
+ // sort by loc
180
+ cssCode.sort((a, b) => a.loc - b.loc);
181
+
182
+ return cssCode.map((code) => code.code).join("\n\n");
183
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * JS Implementation of MurmurHash2
3
+ *
4
+ * @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
5
+ * @see http://github.com/garycourt/murmurhash-js
6
+ * @author <a href="mailto:aappleby@gmail.com">Austin Appleby</a>
7
+ * @see http://sites.google.com/site/murmurhash/
8
+ *
9
+ * @param {string} str ASCII only
10
+ * @return {string} Base 36 encoded hash result
11
+ */
12
+ function murmurhash2_32_gc(str) {
13
+ let l = str.length;
14
+ let h = l;
15
+ let i = 0;
16
+ let k;
17
+
18
+ while (l >= 4) {
19
+ k =
20
+ (str.charCodeAt(i) & 0xff) |
21
+ ((str.charCodeAt(++i) & 0xff) << 8) |
22
+ ((str.charCodeAt(++i) & 0xff) << 16) |
23
+ ((str.charCodeAt(++i) & 0xff) << 24);
24
+
25
+ k =
26
+ (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16);
27
+ k ^= k >>> 24;
28
+ k =
29
+ (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16);
30
+
31
+ h =
32
+ ((h & 0xffff) * 0x5bd1e995 +
33
+ ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16)) ^
34
+ k;
35
+
36
+ l -= 4;
37
+ ++i;
38
+ } // forgive existing code
39
+
40
+ /* eslint-disable no-fallthrough */ switch (l) {
41
+ case 3:
42
+ h ^= (str.charCodeAt(i + 2) & 0xff) << 16;
43
+ case 2:
44
+ h ^= (str.charCodeAt(i + 1) & 0xff) << 8;
45
+ case 1:
46
+ h ^= str.charCodeAt(i) & 0xff;
47
+ h =
48
+ (h & 0xffff) * 0x5bd1e995 +
49
+ ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16);
50
+ }
51
+ /* eslint-enable no-fallthrough */
52
+
53
+ h ^= h >>> 13;
54
+ h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16);
55
+ h ^= h >>> 15;
56
+
57
+ return (h >>> 0).toString(36);
58
+ }
59
+
60
+ module.exports = murmurhash2_32_gc;
@@ -0,0 +1,17 @@
1
+ /// @ts-check
2
+
3
+ /**
4
+ * @type {Promise<{ replaces?: Record<string, Record<string, string>> }>}
5
+ */
6
+ let cache;
7
+ module.exports = function loadConfigOnce(loader) {
8
+ const config = cache || loader().catch((e) => {
9
+ console.error("Failed to load yak config:", e);
10
+ return {};
11
+ })
12
+
13
+ if (!cache) {
14
+ cache = config;
15
+ }
16
+ return config;
17
+ };
@@ -0,0 +1,33 @@
1
+ /// @ts-check
2
+ const stripCssComments = require("./stripCssComments.cjs");
3
+
4
+ /**
5
+ * Checks a quasiValue and returns its type
6
+ *
7
+ * - empty: no expressions, no text
8
+ * - partialStart: starts with a `{`
9
+ * - partialEnd: does not end with a `}` or `;`
10
+ *
11
+ * @param {string} quasiValue
12
+ * @returns {{
13
+ * empty: boolean,
14
+ * partialStart: boolean,
15
+ * partialEnd: boolean,
16
+ * }}
17
+ */
18
+ module.exports = function quasiClassifier(quasiValue) {
19
+ const trimmed = stripCssComments(quasiValue).trim();
20
+ if (trimmed === "") {
21
+ return {
22
+ empty: true,
23
+ partialStart: false,
24
+ partialEnd: false,
25
+ }
26
+ }
27
+
28
+ return {
29
+ empty: false,
30
+ partialStart: trimmed.startsWith("{"),
31
+ partialEnd: !trimmed.endsWith("}") && !trimmed.endsWith(";"),
32
+ }
33
+ }