43081j / postcss-lit

PostCSS syntax for extracting CSS from template literals inside JS/TS files
84 stars 6 forks source link

Support both keys and value as an expression #33

Closed kutnickclose closed 1 year ago

kutnickclose commented 1 year ago

This css can't be parsed by postcssParse

  css`
   ${expr1}: ${expr2}
  `

This gets converted to:

/*POSTCSS_LIT:0*/: /*POSTCSS_LIT:1*/

Which throws an error while parsing. image

I'm not sure the best solve...possibly wrap the whole line in a comment if there are multiple expressions?

kutnickclose commented 1 year ago

Also, another case that's giving me trouble is with at-rules expressions and the case above but maybe solving the above will help solve this issue:

  css`
  ${atRuleExpr} {
    ${expr1}: ${expr2} 
  }
 `
43081j commented 1 year ago

this is probably related to #6.

there's a possibility the lit analyzer currently being worked on by the lit team will be able to parse HTML and CSS templates once it is finished. if thats the case, we can use it in this plugin and will be able to handle all binding positions properly.

otherwise, we will have to write our own smarter substitution code (which really comes close to writing a mini parser...).

i'll reach out to the lit team again so we can find out what the path forward should be.

kutnickclose commented 1 year ago

I did a little experimentation this weekend and was able to get this test to pass using "smarter substitution code"

  it('should parse and stringify expressions', () => {
    const { source, ast } = createTestAst(`
      css\`
        \${expr0}
        .foo { \${expr1}: \${expr2}; }
        \${expr3} { 
            .bar { color: black}
        }
        \${expr4}
      \`;
    `);

    const output = ast.toString(syntax);
    expect(output).toEqual(source);
  });

"smarter" substitution code:

//utils.js
const isAtRule = (sourceAsString: string, indexAfterExpression: number) => {
  return sourceAsString[indexAfterExpression + 1] === '{';
};

const isProperty = (sourceAsString: string, indexAfterExpression: number) => {
  return sourceAsString[indexAfterExpression] === ':';
};

const isRuleSet = (sourceAsString: string, indexAfterExpression: number) => {
  return sourceAsString.indexOf('\n', indexAfterExpression-1) === indexAfterExpression;
};

export const smartCreatePlaceholder = (
  i: number,
  sourceAsString: string,
  indexAfterExpression: number
): string => {
  if (isAtRule(sourceAsString, indexAfterExpression)) {
    return `@POSTCSS_LIT${i}`;
  }
  if (isProperty(sourceAsString, indexAfterExpression)) {
    return `POSTCSS_LIT${i}`;
  }
  if (isRuleSet(sourceAsString, indexAfterExpression)) {
    return `/*POSTCSS_LIT:${i}*/`;
  }

  // assume it's a property value;
  return `POSTCSS_LIT${i}`;
};

after a parse it'll look like this:

    /*POSTCSS_LIT:0*/
    .foo { POSTCSS_LIT1: POSTCSS_LIT2; }
    @POSTCSS_LIT3 {
        .bar { color: black}
    }
    /*POSTCSS_LIT:4*/

Then I needed to update to the stringifier for decl and atRules

//stringify.ts
public override atrule(node: AtRule, semicolon: boolean): void {
    if (node.name.includes('POSTCSS_LIT')) {
      const params = node.params ? this.rawValue(node, 'params') : '';

      const [, expressionIndexString] = node.name.split('POSTCSS_LIT');
      const expressionIndex = Number(expressionIndexString);
      const root = node.root();
      const expressionStrings = root.raws.linariaTemplateExpressions;
      if (expressionStrings && !Number.isNaN(expressionIndex)) {
        const expression = expressionStrings[expressionIndex];

        if (expression) {
          if (node.nodes) {
            this.block(node, expression + params);
          } else {
            const end = (node.raws.between || '') + (semicolon ? ';' : '');
            this.builder(expression + params + end, node);
          }
          return;
        }
      }
    }

    super.atrule(node);
  }

  public override decl(node: Declaration, semicolon: boolean): void {
    let earlyReturn = false;
    if (node.prop.includes('POSTCSS_LIT')) {
      const [, expressionIndexString] = node.prop.split('POSTCSS_LIT');
      const expressionIndex = Number(expressionIndexString);
      const root = node.root();
      const expressionStrings = root.raws.linariaTemplateExpressions;
      if (expressionStrings && !Number.isNaN(expressionIndex)) {
        const expression = expressionStrings[expressionIndex];

        if (expression) {
          this.builder(expression + node.raws.between, node);
          earlyReturn = true;
        }
      }
    }

    if (node.value.includes('POSTCSS_LIT')) {
      const [, expressionIndexString] = node.value.split('POSTCSS_LIT');
      const expressionIndex = Number(expressionIndexString);
      const root = node.root();
      const expressionStrings = root.raws.linariaTemplateExpressions;

      if (expressionStrings && !Number.isNaN(expressionIndex)) {
        let expression = expressionStrings[expressionIndex];
        expression += semicolon ? ';' : '';
        if (expression) {
          this.builder(expression, node);
          earlyReturn = true;
        }
      }
    }

    if (earlyReturn) return;
    super.decl(node);
  }

I think this would break a lot of stylelint rules so I'm not sure if it's a viable solution without turning off or creating exceptions for rules. Or maybe it would work fine if we just add postcsslit to acceptable at-rules, property and values?

43081j commented 1 year ago

thats pretty much what all the old postcss plugins used to do (before custom syntax was a thing), and is roughly what i was planning on doing if the lit team didn't beat me to it with their analyser by then.

it has a lot of edge cases though which, in those cases, lead to either ignoring those cases or an increasingly complex substitution algorithm.

e.g. color: ${c}; i imagine would end up color: ; - easy enough to cover but its one of many, many examples

even a tiny tokeniser might work better but feels like overkill. i really need to have a think about this too but just haven't had time recently 😞

43081j commented 1 year ago

im gonna close this so we can unify these discussions into #6 FYI