From 30f19843f05e1962b9ccba59ef98fb3d98c45f7b Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 13 Dec 2023 16:40:57 +0100 Subject: [PATCH] show error messages for nested mixins which can not be extracted --- .../loaders/__tests__/classifier.test.ts | 133 ++++++++++++++++ .../loaders/__tests__/tsloader.test.ts | 148 ++++++++++++------ .../next-yak/loaders/babel-yak-plugin.cjs | 70 ++++++--- packages/next-yak/loaders/cssloader.cjs | 20 ++- .../loaders/lib/getStyledComponentName.cjs | 4 +- .../next-yak/loaders/lib/getYakImports.cjs | 49 +++--- .../next-yak/loaders/lib/quasiClassifier.cjs | 96 +++++++++--- .../lib/replaceQuasiExpressionTokens.cjs | 24 ++- .../next-yak/loaders/lib/stripCssComments.cjs | 91 ++++++----- packages/next-yak/loaders/tsloader.cjs | 62 +++++--- packages/next-yak/package.json | 2 +- 11 files changed, 501 insertions(+), 198 deletions(-) create mode 100644 packages/next-yak/loaders/__tests__/classifier.test.ts diff --git a/packages/next-yak/loaders/__tests__/classifier.test.ts b/packages/next-yak/loaders/__tests__/classifier.test.ts new file mode 100644 index 00000000..49a060de --- /dev/null +++ b/packages/next-yak/loaders/__tests__/classifier.test.ts @@ -0,0 +1,133 @@ +import quasiClassifier from '../lib/quasiClassifier.cjs'; +import { describe, it, expect } from "vitest"; + +describe('quasiClassifier', () => { + it('should classify empty quasi', () => { + expect(quasiClassifier('', [])).toEqual({ + empty: true, + unknownSelector: false, + insideCssValue: false, + currentNestingScopes: [], + }); + }); + it("should recognize a comment as empty", () => { + expect(quasiClassifier("/* comment */", [])).toEqual({ + empty: true, + unknownSelector: false, + insideCssValue: false, + currentNestingScopes: [], + }); + }); + it("should find incomplete css - unknownSelector", () => { + expect(quasiClassifier("{ color: blue;", [])).toEqual({ + empty: false, + unknownSelector: true, + insideCssValue: false, + currentNestingScopes: [], + }); + }); + it("should find incomplete css - insideCssValue", () => { + expect(quasiClassifier("color: ", [ + ".foo" + ])).toEqual({ + empty: false, + unknownSelector: false, + insideCssValue: true, + currentNestingScopes: [".foo"], + }); + }); + it("should find incomplete css - insideCssValue after a media query", () => { + expect(quasiClassifier("} .foo { color: ", [ + "@media (min-width: 640px)" + ])).toEqual({ + empty: false, + unknownSelector: false, + insideCssValue: true, + currentNestingScopes: [".foo"], + }); + }); + it("should find incomplete css - insideCssValue in a multi value", () => { + expect(quasiClassifier("} .foo { transition: 200ms ", [ + "@media (min-width: 640px)" + ])).toEqual({ + empty: false, + unknownSelector: false, + insideCssValue: true, + currentNestingScopes: [".foo"], + }); + }); + it("should find nesting scopes", () => { + expect(quasiClassifier(` + .foo { + .bar { + @supports (display: grid) { + `, [ + "@media (min-width: 640px)" + ])).toEqual({ + empty: false, + unknownSelector: false, + insideCssValue: false, + currentNestingScopes: [ + "@media (min-width: 640px)", + ".foo", + ".bar", + "@supports (display: grid)", + ], + }); + }); + it("should find nesting scopes with properties", () => { + expect(quasiClassifier(` + .foo { + color: purple; + .x { + color: green; + } + .bar { + color: orange; + @supports (display: grid) { + color: blue; + `, [ + "@media (min-width: 640px)" + ])).toEqual({ + empty: false, + unknownSelector: false, + insideCssValue: false, + currentNestingScopes: [ + "@media (min-width: 640px)", + ".foo", + ".bar", + "@supports (display: grid)", + ], + }); + }); + + it("should find nesting scopes with properties and closing nestings", () => { + expect(quasiClassifier(` + .foo { + /* }}}}} */ + color: purple; + .x { + color: green; + } + .bar { + :before { + content: '{'; + } + color: orange; + @supports (display: grid) { + color: blue; + } + } + `, [ + "@media (min-width: 640px)" + ])).toEqual({ + empty: false, + unknownSelector: false, + insideCssValue: false, + currentNestingScopes: [ + "@media (min-width: 640px)", + ".foo", + ], + }); + }); +}); diff --git a/packages/next-yak/loaders/__tests__/tsloader.test.ts b/packages/next-yak/loaders/__tests__/tsloader.test.ts index ee214473..0a9adb61 100644 --- a/packages/next-yak/loaders/__tests__/tsloader.test.ts +++ b/packages/next-yak/loaders/__tests__/tsloader.test.ts @@ -166,13 +166,12 @@ const FancyButton = styled(Button)\` const FancyButton = styled(Button)(__styleYak.FancyButton_2);" `); }); -}); -it("should support attrs on intrinsic elements", async () => { - expect( - await tsloader.call( - loaderContext, - ` + it("should support attrs on intrinsic elements", async () => { + expect( + await tsloader.call( + loaderContext, + ` import { styled } from "next-yak"; const headline = styled.input.attrs({ @@ -181,21 +180,21 @@ const headline = styled.input.attrs({ color: red; \`; ` - ) - ).toMatchInlineSnapshot(` + ) + ).toMatchInlineSnapshot(` "import { styled } from \\"next-yak\\"; import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\"; const headline = styled.input.attrs({ type: \\"text\\" })(__styleYak.headline_0);" `); -}); + }); -it("should support attrs on wrapped elements", async () => { - expect( - await tsloader.call( - loaderContext, - ` + it("should support attrs on wrapped elements", async () => { + expect( + await tsloader.call( + loaderContext, + ` import { styled } from "next-yak"; const headline = styled.input\` @@ -208,8 +207,8 @@ const newHeadline = styled(headline).attrs({ color: black; \`; ` - ) - ).toMatchInlineSnapshot(` + ) + ).toMatchInlineSnapshot(` "import { styled } from \\"next-yak\\"; import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\"; const headline = styled.input(__styleYak.headline_0); @@ -217,13 +216,13 @@ const newHeadline = styled(headline).attrs({ type: \\"text\\" })(__styleYak.newHeadline_1);" `); -}); + }); -it("should support css variables with spaces", async () => { - expect( - await tsloader.call( - loaderContext, - ` + it("should support css variables with spaces", async () => { + expect( + await tsloader.call( + loaderContext, + ` import styles from "./page.module.css"; import { css } from "next-yak"; import { easing } from "styleguide"; @@ -235,8 +234,8 @@ const headline = css\` \${css\`color: blue\`} \`; ` - ) - ).toMatchInlineSnapshot(` + ) + ).toMatchInlineSnapshot(` "import styles from \\"./page.module.css\\"; import { css } from \\"next-yak\\"; import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\"; @@ -250,13 +249,13 @@ const headline = css\` } });" `); -}); + }); -it("should convert keyframes", async () => { - expect( - await tsloader.call( - loaderContext, - ` + it("should convert keyframes", async () => { + expect( + await tsloader.call( + loaderContext, + ` import styles from "./page.module.css"; import { styled, keyframes } from "next-yak"; @@ -273,8 +272,8 @@ const FadeInButton = styled.button\` animation: 1s \${fadeIn} ease-out; \` ` - ) - ).toMatchInlineSnapshot(` + ) + ).toMatchInlineSnapshot(` "import styles from \\"./page.module.css\\"; import { styled, keyframes } from \\"next-yak\\"; import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\"; @@ -285,13 +284,13 @@ const FadeInButton = styled.button\` } });" `); -}); + }); -it("should allow to target components", async () => { - expect( - await tsloader.call( - loaderContext, - ` + it("should allow to target components", async () => { + expect( + await tsloader.call( + loaderContext, + ` import { styled, keyframes } from "next-yak"; const Link = styled.a\` @@ -317,21 +316,21 @@ const Wrapper = styled.div\` \` ` - ) - ).toMatchInlineSnapshot(` + ) + ).toMatchInlineSnapshot(` "import { styled, keyframes } from \\"next-yak\\"; import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\"; const Link = styled.a(__styleYak.Link_0); const Icon = styled.svg(__styleYak.Icon_1); const Wrapper = styled.div(__styleYak.Wrapper_2);" `); -}); + }); -it("should allow to target components even if they don't have styles", async () => { - expect( - await tsloader.call( - loaderContext, - ` + it("should allow to target components even if they don't have styles", async () => { + expect( + await tsloader.call( + loaderContext, + ` import { styled, keyframes } from "next-yak"; const Link = styled.a\` @@ -347,12 +346,65 @@ const Wrapper = styled.div\` \` ` - ) - ).toMatchInlineSnapshot(` + ) + ).toMatchInlineSnapshot(` "import { styled, keyframes } from \\"next-yak\\"; import __styleYak from \\"./page.yak.module.css!=!./page?./page.yak.module.css\\"; const Link = styled.a(); const Icon = styled.svg(__styleYak.Icon_1); const Wrapper = styled.div(__styleYak.Wrapper_2);" `); + }); + + it("should show error when mixin is used in nested selector", async () => { + await expect(() => + tsloader.call( + loaderContext, + ` +import { styled, css } from "next-yak"; + +const bold = css\` + font-weight: bold; +\` + +const Icon = styled.div\` + @media (min-width: 640px) { + .bar { + \${bold} + } + } +\` +` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "/some/special/path/page.tsx: Expressions are not allowed inside nested selectors: + line 11: found \\"bold\\" inside \\"@media (min-width: 640px) { .bar {\\"" + `); + }); + + it("should show error when mixin is used in nested selector inside a css", async () => { + await expect(() => + tsloader.call( + loaderContext, + ` +import { styled, css } from "next-yak"; + +const bold = css\` + font-weight: bold; +\` + +const Icon = styled.div\` + @media (min-width: 640px) { + .bar { + \${() => css\`\${bold}\`} + } + } +\` +` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "/some/special/path/page.tsx: Expressions are not allowed inside nested selectors: + line 11: found Expression inside \\"@media (min-width: 640px) { .bar {\\"" + `); + }); }); diff --git a/packages/next-yak/loaders/babel-yak-plugin.cjs b/packages/next-yak/loaders/babel-yak-plugin.cjs index 059f8051..70464cef 100644 --- a/packages/next-yak/loaders/babel-yak-plugin.cjs +++ b/packages/next-yak/loaders/babel-yak-plugin.cjs @@ -62,9 +62,9 @@ module.exports = function (babel, options) { t.importDeclaration( [t.importDefaultSpecifier(t.identifier("__styleYak"))], t.stringLiteral( - `./${fileName}.yak.module.css!=!./${fileName}?./${fileName}.yak.module.css` - ) - ) + `./${fileName}.yak.module.css!=!./${fileName}?./${fileName}.yak.module.css`, + ), + ), ); // Process import specifiers @@ -115,7 +115,7 @@ module.exports = function (babel, options) { const isStyledLiteral = t.isMemberExpression(tag) && t.isIdentifier( - /** @type {babel.types.MemberExpression} */ (tag).object + /** @type {babel.types.MemberExpression} */ (tag).object, ) && /** @type {babel.types.Identifier} */ ( /** @type {babel.types.MemberExpression} */ (tag).object @@ -123,7 +123,7 @@ module.exports = function (babel, options) { const isStyledCall = t.isCallExpression(tag) && t.isIdentifier( - /** @type {babel.types.CallExpression} */ (tag).callee + /** @type {babel.types.CallExpression} */ (tag).callee, ) && /** @type {babel.types.Identifier} */ ( /** @type {babel.types.CallExpression} */ (tag).callee @@ -169,15 +169,15 @@ module.exports = function (babel, options) { astNode.arguments.push( t.memberExpression( t.identifier("__styleYak"), - t.identifier(className) - ) + t.identifier(className), + ), ); } return className; } return false; }, - t + t, ); let literalSelectorWasUsed = false; @@ -189,17 +189,21 @@ module.exports = function (babel, options) { localIdent( variableName, literalSelectorIndex, - isKeyframesLiteral ? "animation" : "className" - ) - ) + isKeyframesLiteral ? "animation" : "className", + ), + ), ); // Replace the tagged template expression with a call to the 'styled' function const newArguments = new Set(); const quasis = path.node.quasi.quasis; - const quasiTypes = quasis.map((quasi) => - quasiClassifier(quasi.value.raw) - ); + /** @type {string[]} */ + let currentNestingScopes = []; + const quasiTypes = quasis.map((quasi) => { + const classification = quasiClassifier(quasi.value.raw, currentNestingScopes); + currentNestingScopes = classification.currentNestingScopes; + return classification; + }); const expressions = path.node.quasi.expressions; let cssVariablesInlineStyle; @@ -226,8 +230,8 @@ module.exports = function (babel, options) { const type = quasiTypes[i]; // expressions after a partial css are converted into css variables if ( - type.partialStart || - type.partialEnd || + type.unknownSelector || + type.insideCssValue || (isMerging && type.empty) ) { isMerging = true; @@ -249,7 +253,7 @@ module.exports = function (babel, options) { } const relativePath = relative( rootContext, - resolve(rootContext, resourcePath) + resolve(rootContext, resourcePath), ); hashedFile = murmurhash2_32_gc(relativePath); } @@ -257,14 +261,23 @@ module.exports = function (babel, options) { cssVariablesInlineStyle.properties.push( t.objectProperty( t.stringLiteral(`--🦬${hashedFile}${this.varIndex++}`), - /** @type {babel.types.Expression} */ (expression) - ) + /** @type {babel.types.Expression} */ (expression), + ), ); } else if (type.empty) { - // empty quasis can be ignored + // empty quasis can be ignored in typescript // e.g. `transition: color ${duration} ${easing};` + // ^ } else { if (expressions[i]) { + if (quasiTypes[i].currentNestingScopes.length > 0) { + const errorExpression = expressions[i]; + const name = errorExpression.type === "Identifier" ? `"${errorExpression.name}"` : "Expression"; + const line = errorExpression.loc?.start.line || -1 + throw new InvalidPositionError( + `Expressions are not allowed inside nested selectors:\n${line !== -1 ? `line ${line}: ` : ""}found ${name} inside "${quasiTypes[i].currentNestingScopes.join(" { ")} {"`, + ); + } newArguments.add(expressions[i]); } break; @@ -277,9 +290,9 @@ module.exports = function (babel, options) { t.objectExpression([ t.objectProperty( t.stringLiteral(`style`), - cssVariablesInlineStyle + cssVariablesInlineStyle, ), - ]) + ]), ); } @@ -294,7 +307,7 @@ module.exports = function (babel, options) { className: localIdent( variableName, literalSelectorIndex, - "className" + "className", ), astNode: styledCall, }); @@ -303,3 +316,14 @@ module.exports = function (babel, options) { }, }; }; + +class InvalidPositionError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message); + } +} + +module.exports.InvalidPositionError = InvalidPositionError; \ No newline at end of file diff --git a/packages/next-yak/loaders/cssloader.cjs b/packages/next-yak/loaders/cssloader.cjs index cf2e8557..2236cfb9 100644 --- a/packages/next-yak/loaders/cssloader.cjs +++ b/packages/next-yak/loaders/cssloader.cjs @@ -34,7 +34,7 @@ module.exports = async function cssLoader(source) { imports.forEach(({ localName, importedName }) => { replaces[localName] = constantValues[importedName]; }); - }) + }), ); // parse source with babel @@ -127,7 +127,7 @@ module.exports = async function cssLoader(source) { const isStyledLiteral = t.isMemberExpression(tag) && t.isIdentifier( - /** @type {babel.types.MemberExpression} */ (tag).object + /** @type {babel.types.MemberExpression} */ (tag).object, ) && /** @type {babel.types.Identifier} */ ( /** @type {babel.types.MemberExpression} */ (tag).object @@ -136,7 +136,7 @@ module.exports = async function cssLoader(source) { const isStyledCall = t.isCallExpression(tag) && t.isIdentifier( - /** @type {babel.types.CallExpression} */ (tag).callee + /** @type {babel.types.CallExpression} */ (tag).callee, ) && /** @type {babel.types.Identifier} */ ( /** @type {babel.types.CallExpression} */ (tag).callee @@ -176,7 +176,7 @@ module.exports = async function cssLoader(source) { } return false; }, - t + t, ); // Keep the same selector for all quasis belonging to the same css block @@ -184,14 +184,12 @@ module.exports = async function cssLoader(source) { const literalSelector = localIdent( variableName, literalSelectorIndex, - isKeyFrameLiteral ? "keyframes" : "selector" + isKeyFrameLiteral ? "keyframes" : "selector", ); // Replace the tagged template expression with a call to the 'styled' function const quasis = path.node.quasi.quasis; - const quasiTypes = quasis.map((quasi) => - quasiClassifier(quasi.value.raw) - ); + const quasiTypes = quasis.map((quasi) => quasiClassifier(quasi.value.raw, [])); for (let i = 0; i < quasis.length; i++) { const quasi = quasis[i]; @@ -206,8 +204,8 @@ module.exports = async function cssLoader(source) { const type = quasiTypes[i]; // expressions after a partial css are converted into css variables if ( - type.partialStart || - type.partialEnd || + type.unknownSelector || + type.insideCssValue || (isMerging && type.empty) ) { isMerging = true; @@ -243,7 +241,7 @@ module.exports = async function cssLoader(source) { if (isStyledLiteral || isStyledCall || isAttrsCall) { variableNameToStyledClassName.set( variableName, - localIdent(variableName, literalSelectorIndex, "selector") + localIdent(variableName, literalSelectorIndex, "selector"), ); } }, diff --git a/packages/next-yak/loaders/lib/getStyledComponentName.cjs b/packages/next-yak/loaders/lib/getStyledComponentName.cjs index fcb60683..19478b1c 100644 --- a/packages/next-yak/loaders/lib/getStyledComponentName.cjs +++ b/packages/next-yak/loaders/lib/getStyledComponentName.cjs @@ -13,7 +13,7 @@ */ const getStyledComponentName = (taggedTemplateExpressionPath) => { const variableDeclaratorPath = taggedTemplateExpressionPath.findParent( - (path) => path.isVariableDeclarator() + (path) => path.isVariableDeclarator(), ); if ( !variableDeclaratorPath || @@ -22,7 +22,7 @@ const getStyledComponentName = (taggedTemplateExpressionPath) => { ) { throw new Error( "Could not find variable declaration for styled component at " + - taggedTemplateExpressionPath.node.loc + taggedTemplateExpressionPath.node.loc, ); } return variableDeclaratorPath.node.id.name; diff --git a/packages/next-yak/loaders/lib/getYakImports.cjs b/packages/next-yak/loaders/lib/getYakImports.cjs index 0028f28b..ca4813d1 100644 --- a/packages/next-yak/loaders/lib/getYakImports.cjs +++ b/packages/next-yak/loaders/lib/getYakImports.cjs @@ -1,38 +1,47 @@ // @ts-check /** * Finds all imports in a given code string which import from a .yak file - * + * * Uses regex to work with typescript and javascript * Does not support lazy imports - * + * * @param {string} code * @returns { { imports: { localName: string, importedName: string }[], from: string }[] } */ const getYakImports = (code) => { - const codeWithoutComments = code.replace(/\/\*[\s\S]*?\*\//g, ''); - const allImports = codeWithoutComments.matchAll(/(^|\n|;)\s*import\s+(?:(\w+(?:\s+as\s+\w+)?)\s*,?\s*)?(?:{([^}]*)})?\s+from\s+["']([^'"]+\.yak)["'](;|\n)/g); - return [...allImports].map(([, , defaultImport, namedImports, from,]) => { - // parse named imports to { localName: string, importedName: string }[] - const imports = namedImports?.split(',').map((namedImport) => { - const [importedName, localName = importedName] = namedImport.replace(/^type\s+/, "").trim().split(/\s+as\s+/); - return { localName, importedName }; - }) ?? []; - // parse default import to { localName: string, importedName: string }[] - if (defaultImport) { - imports.push(parseDefaultImport(defaultImport)); - } - return { imports, from }; - }); + const codeWithoutComments = code.replace(/\/\*[\s\S]*?\*\//g, ""); + const allImports = codeWithoutComments.matchAll( + /(^|\n|;)\s*import\s+(?:(\w+(?:\s+as\s+\w+)?)\s*,?\s*)?(?:{([^}]*)})?\s+from\s+["']([^'"]+\.yak)["'](;|\n)/g, + ); + return [...allImports].map(([, , defaultImport, namedImports, from]) => { + // parse named imports to { localName: string, importedName: string }[] + const imports = + namedImports?.split(",").map((namedImport) => { + const [importedName, localName = importedName] = namedImport + .replace(/^type\s+/, "") + .trim() + .split(/\s+as\s+/); + return { localName, importedName }; + }) ?? []; + // parse default import to { localName: string, importedName: string }[] + if (defaultImport) { + imports.push(parseDefaultImport(defaultImport)); + } + return { imports, from }; + }); }; /** * Parses a default import string * @param {string} defaultImport * @returns {{ localName: string, importedName: string }} - */ + */ function parseDefaultImport(defaultImport) { - const defaultImportArray = defaultImport.split(/\s+as\s+/); - return { localName: defaultImportArray[1] ?? defaultImportArray[0], importedName: defaultImportArray[0] } + const defaultImportArray = defaultImport.split(/\s+as\s+/); + return { + localName: defaultImportArray[1] ?? defaultImportArray[0], + importedName: defaultImportArray[0], + }; } -module.exports = getYakImports; \ No newline at end of file +module.exports = getYakImports; diff --git a/packages/next-yak/loaders/lib/quasiClassifier.cjs b/packages/next-yak/loaders/lib/quasiClassifier.cjs index 14bda58c..6462666f 100644 --- a/packages/next-yak/loaders/lib/quasiClassifier.cjs +++ b/packages/next-yak/loaders/lib/quasiClassifier.cjs @@ -3,31 +3,85 @@ const stripCssComments = require("./stripCssComments.cjs"); /** * Checks a quasiValue and returns its type - * + * * - empty: no expressions, no text - * - partialStart: starts with a `{` - * - partialEnd: does not end with a `}` or `;` - * - * @param {string} quasiValue + * - unknownSelector: starts with a `{` e.g. `{ opacity: 0.5; }` + * - insideCssValue: does not end with a `{` or `}` or `;` e.g. `color: ` + * + * @param {string} quasiValue + * @param {string[]} currentNestingScopes - the current nesting scope + * * @returns {{ * empty: boolean, - * partialStart: boolean, - * partialEnd: boolean, + * unknownSelector: boolean, + * insideCssValue: boolean, + * currentNestingScopes: string[], * }} */ -module.exports = function quasiClassifier(quasiValue) { - const trimmed = stripCssComments(quasiValue).trim(); - if (trimmed === "") { - return { - empty: true, - partialStart: false, - partialEnd: false, - } - } - +module.exports = function quasiClassifier(quasiValue, currentNestingScopes) { + // TODO - for better performance we could move the comment skipping logic + // directly in the for loop below instead of calling stripCssComments + const trimmedCssString = stripCssComments(quasiValue).trim(); + if (trimmedCssString === "") { return { - empty: false, - partialStart: trimmed.startsWith("{"), - partialEnd: !trimmed.endsWith("}") && !trimmed.endsWith(";"), + empty: true, + unknownSelector: false, + insideCssValue: false, + currentNestingScopes, + }; + } + /** @type {'"' | "'" | false} */ + let isInsideString = false; + let currentCharacter = ""; + let newNestingLevel = [...currentNestingScopes]; + let currentSelector = ""; + for (let index = 0; index < trimmedCssString.length; index++) { + currentCharacter = trimmedCssString[index]; + if ( + trimmedCssString[index - 1] !== "\\" && + (currentCharacter === '"' || currentCharacter === "'") + ) { + if (isInsideString === currentCharacter) { + isInsideString = false; + } else if (!isInsideString) { + isInsideString = currentCharacter; + } + } + if (isInsideString) { + continue; } -} \ No newline at end of file + if (currentCharacter === "{") { + const selector = currentSelector.trim(); + if (selector !== "") { + newNestingLevel.push(selector); + } + // after an opening bracket a new selector might start e.g.: + // .class { + // .nested-class { + currentSelector = ""; + } else if (currentCharacter === "}") { + newNestingLevel.pop(); + // after a closing bracket a new selector might start e.g.: + // .class { + // color: red; + // } + // .other-class { + currentSelector = ""; + } else if (currentCharacter === ";") { + // after a semi-colon a nested selector might start e.g.: + // .class { + // color: red; + // .nested-class { + currentSelector = ""; + } else { + currentSelector += currentCharacter; + } + } + + return { + empty: false, + unknownSelector: trimmedCssString[0] === "{", + insideCssValue: currentCharacter !== "{" && currentCharacter !== "}" && currentCharacter !== ";", + currentNestingScopes: newNestingLevel, + }; +}; diff --git a/packages/next-yak/loaders/lib/replaceQuasiExpressionTokens.cjs b/packages/next-yak/loaders/lib/replaceQuasiExpressionTokens.cjs index 9388026c..3bc58998 100644 --- a/packages/next-yak/loaders/lib/replaceQuasiExpressionTokens.cjs +++ b/packages/next-yak/loaders/lib/replaceQuasiExpressionTokens.cjs @@ -35,10 +35,13 @@ module.exports = function replaceTokensInQuasiExpressions(quasi, replacer, t) { } replaceExpressionAndMergeQuasis(quasi, i, replacement); i--; - } + } // replace member expressions e.g. ${query.xs} // replace deeply nested member expressions e.g. ${query.xs.min} - else if (t.isMemberExpression(expression) && t.isIdentifier(expression.object)) { + else if ( + t.isMemberExpression(expression) && + t.isIdentifier(expression.object) + ) { /** @type {any} */ let replacement = replacer(expression.object.name); if (replacement === false) { @@ -60,7 +63,7 @@ module.exports = function replaceTokensInQuasiExpressions(quasi, replacer, t) { i--; } } -} +}; /** * Replace tokens with predefined values @@ -69,9 +72,16 @@ module.exports = function replaceTokensInQuasiExpressions(quasi, replacer, t) { * @param {unknown} replacement */ function replaceExpressionAndMergeQuasis(quasi, expressionIndex, replacement) { - const stringReplacement = typeof replacement === "string" ? replacement : replacement == null ? "" : JSON.stringify(replacement); + const stringReplacement = + typeof replacement === "string" + ? replacement + : replacement == null + ? "" + : JSON.stringify(replacement); quasi.expressions.splice(expressionIndex, 1); - quasi.quasis[expressionIndex].value.raw += stringReplacement + quasi.quasis[expressionIndex + 1].value.raw; - quasi.quasis[expressionIndex].value.cooked += stringReplacement + quasi.quasis[expressionIndex + 1].value.cooked; + quasi.quasis[expressionIndex].value.raw += + stringReplacement + quasi.quasis[expressionIndex + 1].value.raw; + quasi.quasis[expressionIndex].value.cooked += + stringReplacement + quasi.quasis[expressionIndex + 1].value.cooked; quasi.quasis.splice(expressionIndex + 1, 1); -} \ No newline at end of file +} diff --git a/packages/next-yak/loaders/lib/stripCssComments.cjs b/packages/next-yak/loaders/lib/stripCssComments.cjs index cfc7b016..4898ce9b 100644 --- a/packages/next-yak/loaders/lib/stripCssComments.cjs +++ b/packages/next-yak/loaders/lib/stripCssComments.cjs @@ -1,51 +1,58 @@ /// @ts-check // from https://github.com/sindresorhus/strip-css-comments/tree/main /** - * - * @param {string} cssString + * + * @param {string} cssString */ module.exports = function stripCssComments(cssString) { - /** @type {string | false} */ - let isInsideString = false; - let currentCharacter = ''; - let comment = ''; - let returnValue = ''; + /** @type {string | false} */ + let isInsideString = false; + let currentCharacter = ""; + let comment = ""; + let returnValue = ""; - for (let index = 0; index < cssString.length; index++) { - currentCharacter = cssString[index]; + for (let index = 0; index < cssString.length; index++) { + currentCharacter = cssString[index]; - if (cssString[index - 1] !== '\\' && (currentCharacter === '"' || currentCharacter === '\'')) { - if (isInsideString === currentCharacter) { - isInsideString = false; - } else if (!isInsideString) { - isInsideString = currentCharacter; - } - } + if ( + cssString[index - 1] !== "\\" && + (currentCharacter === '"' || currentCharacter === "'") + ) { + if (isInsideString === currentCharacter) { + isInsideString = false; + } else if (!isInsideString) { + isInsideString = currentCharacter; + } + } - // Find beginning of `/*` type comment - if (!isInsideString && currentCharacter === '/' && cssString[index + 1] === '*') { - let index2 = index + 2; + // Find beginning of `/*` type comment + if ( + !isInsideString && + currentCharacter === "/" && + cssString[index + 1] === "*" + ) { + let index2 = index + 2; - // Iterate over comment - for (; index2 < cssString.length; index2++) { - // Find end of comment - if (cssString[index2] === '*' && cssString[index2 + 1] === '/') { - if (cssString[index2 + 2] === '\n') { - index2++; - } else if (cssString[index2 + 2] + cssString[index2 + 3] === '\r\n') { - index2 += 2; - } - comment = ''; - break; - } - // Store comment text - comment += cssString[index2]; - } - // Resume iteration over CSS string from the end of the comment - index = index2 + 1; - continue; - } - returnValue += currentCharacter; - } - return returnValue; -} \ No newline at end of file + // Iterate over comment + for (; index2 < cssString.length; index2++) { + // Find end of comment + if (cssString[index2] === "*" && cssString[index2 + 1] === "/") { + if (cssString[index2 + 2] === "\n") { + index2++; + } else if (cssString[index2 + 2] + cssString[index2 + 3] === "\r\n") { + index2 += 2; + } + comment = ""; + break; + } + // Store comment text + comment += cssString[index2]; + } + // Resume iteration over CSS string from the end of the comment + index = index2 + 1; + continue; + } + returnValue += currentCharacter; + } + return returnValue; +}; diff --git a/packages/next-yak/loaders/tsloader.cjs b/packages/next-yak/loaders/tsloader.cjs index 83fcd862..45482ba7 100644 --- a/packages/next-yak/loaders/tsloader.cjs +++ b/packages/next-yak/loaders/tsloader.cjs @@ -1,6 +1,7 @@ /// @ts-check const babel = require("@babel/core"); const getYakImports = require("./lib/getYakImports.cjs"); +const InvalidPositionError = require("./babel-yak-plugin.cjs").InvalidPositionError; /** * Loader for typescript files that use yak, it replaces the css template literal with a call to the 'styled' function @@ -20,34 +21,49 @@ module.exports = async function tsloader(source) { const isYakFile = /\.yak\.(j|t)sx?$/.test(resourcePath.matches); // The user may import constants from a .yak file // e.g. import { primary } from './colors.yak' - // + // // However .yak files inside .yak files are not be compiled // to avoid performance overhead - const importedYakConstantNames = isYakFile ? [] : getYakImports(source).map(({ imports }) => imports.map(({ localName }) => localName)).flat(2); - const replaces = Object.fromEntries(importedYakConstantNames.map((name) => [name, null])); + const importedYakConstantNames = isYakFile + ? [] + : getYakImports(source) + .map(({ imports }) => imports.map(({ localName }) => localName)) + .flat(2); + const replaces = Object.fromEntries( + importedYakConstantNames.map((name) => [name, null]) + ); - // Compile the typescript file with babel - this will: - // - inject the import to the css-module (with .yak.module.css extension) - // - replace the css template literal with styles from the css-module - const result = babel.transformSync(source, { - filename: resourcePath, - configFile: false, - plugins: [ - [ - "@babel/plugin-syntax-typescript", - { isTSX: this.resourcePath.endsWith(".tsx") }, + /** @type {babel.BabelFileResult | null} */ + let result = null; + try { + // Compile the typescript file with babel - this will: + // - inject the import to the css-module (with .yak.module.css extension) + // - replace the css template literal with styles from the css-module + result = babel.transformSync(source, { + filename: resourcePath, + configFile: false, + plugins: [ + [ + "@babel/plugin-syntax-typescript", + { isTSX: this.resourcePath.endsWith(".tsx") }, + ], + [ + require.resolve("./babel-yak-plugin.cjs"), + { + replaces, + rootContext, + }, + ], ], - [ - require.resolve("./babel-yak-plugin.cjs"), - { - replaces, - rootContext, - }, - ], - ], - }); + }); + } catch (error) { + if (error instanceof InvalidPositionError) { + return callback(new Error(error.message)); + } + return callback(new Error("babel transform failed")); + } if (!result?.code) { - throw new Error("babel transform failed"); + return callback(new Error("babel transform failed")); } return callback(null, result.code, result.map); }; diff --git a/packages/next-yak/package.json b/packages/next-yak/package.json index 76fd1040..0803df6a 100644 --- a/packages/next-yak/package.json +++ b/packages/next-yak/package.json @@ -1,6 +1,6 @@ { "name": "next-yak", - "version": "0.0.18", + "version": "0.0.19", "type": "module", "types": "./dist/", "exports": {