diff --git a/packages/core/src/process-declaration-functions.ts b/packages/core/src/process-declaration-functions.ts index e01f91b76..f01b5e7c6 100644 --- a/packages/core/src/process-declaration-functions.ts +++ b/packages/core/src/process-declaration-functions.ts @@ -1,32 +1,36 @@ import type { Declaration } from 'postcss'; import { AnyValueNode, parseValues, stringifyValues } from 'css-selector-tokenizer'; +type OnFunction = (node: AnyValueNode, level: number) => void; + export function processDeclarationFunctions( decl: Declaration, - onFunction: (node: AnyValueNode) => void, + onFunction: OnFunction, transform = false ) { const ast = parseValues(decl.value); - ast.nodes.forEach((node) => findFunction(node, onFunction)); + ast.nodes.forEach((node) => findFunction(node, onFunction, 1)); if (transform) { decl.value = stringifyValues(ast); } } -function findFunction(node: AnyValueNode, onFunctionNode: (node: AnyValueNode) => void) { +function findFunction(node: AnyValueNode, onFunctionNode: OnFunction, level: number) { switch (node.type) { case 'value': case 'values': - node.nodes.forEach((child) => findFunction(child, onFunctionNode)); + onFunctionNode(node, level); + node.nodes.forEach((child) => findFunction(child, onFunctionNode, level)); break; case 'url': - onFunctionNode(node); + case 'item': + onFunctionNode(node, level); break; case 'nested-item': - onFunctionNode(node); - node.nodes.forEach((child) => findFunction(child, onFunctionNode)); + onFunctionNode(node, level); + node.nodes.forEach((child) => findFunction(child, onFunctionNode, level + 1)); break; } } diff --git a/packages/core/src/stylable-transformer.ts b/packages/core/src/stylable-transformer.ts index 5f92a5edb..74d8e434c 100644 --- a/packages/core/src/stylable-transformer.ts +++ b/packages/core/src/stylable-transformer.ts @@ -49,10 +49,12 @@ export interface KeyFrameWithNode { node: postcss.Node; } +type StVar = string | { [key: string]: StVar } | StVar[]; + export interface StylableExports { classes: Record; vars: Record; - stVars: Record; + stVars: Record; keyframes: Record; } @@ -263,7 +265,7 @@ export class StylableTransformer { } public exportLocalVars( meta: StylableMeta, - stVarsExport: Record, + stVarsExport: StylableExports['stVars'], variableOverride?: Record ) { for (const varSymbol of meta.vars) { diff --git a/packages/module-utils/src/dts-rough-tokenizer.ts b/packages/module-utils/src/dts-rough-tokenizer.ts index a2703d763..2d11da1af 100644 --- a/packages/module-utils/src/dts-rough-tokenizer.ts +++ b/packages/module-utils/src/dts-rough-tokenizer.ts @@ -70,6 +70,11 @@ export function getLocalClassStates(local: string, tokens: TokenizedDtsEntry[]) throw new Error(`Could not find states for class ${local}`); } +const parenthesesClosures = { + '}': '{', + ']': '[', +} as const; + const isDelimiter = (char: string) => char === ':' || char === ';' || @@ -221,6 +226,8 @@ function findDtsTokens(tokens: DTSCodeToken[]) { s.peek().value === 'const' && isRelevantKey(s.peek(2).value) ) { + const levels: { [key in '{' | '[']?: number } = {}; + const values = new WeakSet(); const start = t.start; s.next(); // const const declareType = s.next(); // name @@ -228,27 +235,53 @@ function findDtsTokens(tokens: DTSCodeToken[]) { const resTokens: DtsToken[] = []; // {...resTokens[]} while ((t = s.next())) { - if (!t.type || t.type === '}') { + if (values.has(t)) { + // registered as value of token + continue; + } + if (!t.type) { break; } + if (t.type === '{' || t.type === '[') { + if (!levels[t.type]) { + levels[t.type] = 0; + } + + levels[t.type]!++; + } + + if (t.type === '}' || t.type === ']') { + levels[parenthesesClosures[t.type]]!--; + + if (Object.values(levels).every((level) => level <= 0)) { + break; + } + } if (t.type === '\n') { lastNewLinePosition.line += 1; lastNewLinePosition.columm = t.end; - } else if (t.type === 'string') { - s.next(); // : - const value = s.next(); // value - s.next(); // ; - resTokens.push({ + } + if (t.type === 'string') { + const token: DtsToken = { ...t, line: lastNewLinePosition.line, column: t.start - lastNewLinePosition.columm, - outputValue: { + }; + + // in case this token has a string value token we add it to current token object + const value = s.peek(2); + if (value.type === 'string' || value.type === 'text') { + values.add(value); + + token.outputValue = { ...value, line: lastNewLinePosition.line, column: value.start - lastNewLinePosition.columm, - }, - }); + }; + } + + resTokens.push(token); } } diff --git a/packages/module-utils/src/generate-dts-sourcemaps.ts b/packages/module-utils/src/generate-dts-sourcemaps.ts index a63ba022a..3cf86f950 100644 --- a/packages/module-utils/src/generate-dts-sourcemaps.ts +++ b/packages/module-utils/src/generate-dts-sourcemaps.ts @@ -1,6 +1,7 @@ import { basename } from 'path'; import { ClassSymbol, StylableMeta, valueMapping } from '@stylable/core'; import { CSSKeyframes } from '@stylable/core/dist/features'; +import { processDeclarationFunctions } from '@stylable/core/dist/process-declaration-functions'; import { encode } from 'vlq'; import { ClassesToken, @@ -8,6 +9,7 @@ import { TokenizedDtsEntry, tokenizeDTS, } from './dts-rough-tokenizer'; +import { SPACING } from './generate-dts'; type LineMapping = Array>; @@ -50,25 +52,57 @@ function getVarsSrcPosition(varName: string, meta: StylableMeta): Position | und function getStVarsSrcPosition(varName: string, meta: StylableMeta): Position | undefined { const stVar = meta.vars.find((v) => v.name === varName); - let res; - if (stVar) { + if (stVar?.node.source?.start) { + return { + line: stVar.node.source.start.line - 1, + column: stVar.node.source.start.column - 1, + }; + } else { + // TODO: move this logic to Stylable core and enhance it. The meta should provide the API to get to the inner parts of the st-var + let res: Position; meta.rawAst.walkRules(':vars', (rule) => { - return rule.walkDecls(varName, (decl) => { - if (decl.source && decl.source.start) { - res = { - line: decl.source.start.line - 1, - column: decl.source.start.column - 1, - }; - return false; + return rule.walkDecls((decl) => { + if (decl.source?.start) { + if (decl.prop === varName) { + res = { + line: decl.source.start.line - 1, + column: decl.source.start.column - 1, + }; + } else { + processDeclarationFunctions(decl, (node, level) => { + if (node.type === 'item' && node.name === varName) { + const rawDeclaration = `${decl.raws.before ?? ''}${decl.prop}${ + decl.raws.between ?? '' + }${decl.value}`; + const rootPosition = { + line: rule.source!.start!.line - 1, + column: rule.source!.start!.column - 1, + }; + + res = { + ...calculateEstimatedPosition( + rawDeclaration, + node.name, + node.after, + rootPosition + ), + generatedOffsetLevel: level, + }; + } + }); + } + + if (res) { + return false; + } } return; }); }); + return res!; } - - return res; } function getKeyframeSrcPosition(keyframeName: string, meta: StylableMeta): Position | undefined { @@ -107,6 +141,7 @@ function createLineMapping(dtsOffset: number, srcLine: number, srcCol: number): type Position = { line: number; column: number; + generatedOffsetLevel?: number; }; function findDefiningClassName(stateToken: ClassStateToken, entryClassName: ClassSymbol) { @@ -236,11 +271,18 @@ export function generateDTSSourceMap(dtsContent: string, meta: StylableMeta) { } if (currentSrcPosition) { + const lineDelta = currentSrcPosition.line - lastSrcPosition.line; + const columnDelta = currentSrcPosition.column - lastSrcPosition.column; + mapping[dtsLine] = createLineMapping( - 4, // top-level object property offset - currentSrcPosition.line - lastSrcPosition.line, - currentSrcPosition.column - lastSrcPosition.column + SPACING.repeat(currentSrcPosition.generatedOffsetLevel ?? 1).length, + lineDelta, + columnDelta ); + + // reset to default offset level + currentSrcPosition.generatedOffsetLevel = undefined; + lastSrcPosition = { ...currentSrcPosition }; } } else if (resToken.type === 'states') { @@ -280,3 +322,20 @@ export function generateDTSSourceMap(dtsContent: string, meta: StylableMeta) { 4 ); } + +function calculateEstimatedPosition( + rawValue: string, + name: string, + after = '', + rootPosition?: Position +): Position { + const valueLength = rawValue.indexOf(name + after) + name.length - after.length; + const value = rawValue.slice(0, valueLength); + const byLines = value.split(/\n/g); + const lastLine = byLines[byLines.length - 1]; + + return { + line: byLines.length - 1 + (rootPosition?.line ?? 0), + column: lastLine.length + (rootPosition?.column ?? 0), + }; +} diff --git a/packages/module-utils/src/generate-dts.ts b/packages/module-utils/src/generate-dts.ts index 5926ddbeb..643ee5141 100644 --- a/packages/module-utils/src/generate-dts.ts +++ b/packages/module-utils/src/generate-dts.ts @@ -8,7 +8,7 @@ import { valueMapping, } from '@stylable/core'; -const SPACING = ' '.repeat(4); +export const SPACING = ' '.repeat(4); const asString = (v: string) => JSON.stringify(v); function addStatesEntries( @@ -63,10 +63,46 @@ function stringifyStates(meta: StylableMeta) { return out; } -function stringifyStringRecord(record: Record, indent = SPACING) { - return Object.keys(record) - .map((k) => `${indent}${asString(k)}: string;`) - .join('\n'); +function stringifyStringRecord( + record: Record, + addParentheses = false, + indent = SPACING, + delimiter = '\n' +): string { + const s = Object.entries(record) + .map( + ([key, value]) => + `${indent}${asString(key)}: ${stringifyTypedValue( + value, + indent + SPACING, + delimiter + )};` + ) + .join(delimiter); + + return addParentheses ? `{${wrapNL(s)}${indent.replace(SPACING, '')}}` : s; +} + +function stringifyStringArray(array: any[], indent = SPACING, delimiter = '\n') { + return `[${wrapNL( + array + .map((value) => `${indent}${stringifyTypedValue(value, indent + SPACING, delimiter)},`) + .join(delimiter) + )}${indent.replace(SPACING, '')}]`; +} + +function stringifyTypedValue( + value: string | any[] | Record, + indent = SPACING, + delimiter = '\n' +): string { + if (typeof value === 'string') { + return 'string'; + } else if (Array.isArray(value)) { + return stringifyStringArray(value, indent, delimiter); + } else { + return stringifyStringRecord(value, true, indent, delimiter); + } } function stringifyClasses(classes: Record, namespace: string, indent = SPACING) { diff --git a/packages/module-utils/test/dts-rough-tokenizer.spec.ts b/packages/module-utils/test/dts-rough-tokenizer.spec.ts index 6e5134c7d..9e867c871 100644 --- a/packages/module-utils/test/dts-rough-tokenizer.spec.ts +++ b/packages/module-utils/test/dts-rough-tokenizer.spec.ts @@ -34,4 +34,92 @@ describe('tokenizeDTS (e2e)', () => { expect(states[2].type[2].value, 'stateValue Type enum b').to.equal('"b"'); expect(states[2].type[4].value, 'stateValue Type enum c').to.equal('"c"'); }); + + it('should tokenize complex st-vars', () => { + tk.populate({ + 'test.st.css': ` + :vars { + simple: red; + a: st-map(b st-array(red, + blue, + st-map(c green, d gold))); + }`, + }); + const dts = tk.read('test.st.css.d.ts'); + + const out = tokenizeDTS(dts); + + const stVars = out.find(({ type }) => type === 'stVars')!; + const simple = stVars.tokens[0]; + const a = stVars.tokens[1]; + const b = stVars.tokens[2]; + const c = stVars.tokens[3]; + const d = stVars.tokens[4]; + + expect(stVars.tokens.length, 'generate all st-vars tokens').to.equal(5); + expect(simple, 'generate token for variable "simple"').to.eql({ + value: '"simple"', + type: 'string', + start: 210, + end: 218, + line: 12, + column: 4, + outputValue: { + value: 'string', + type: 'text', + start: 220, + end: 226, + line: 12, + column: 14, + }, + }); + expect(a, 'generate token for variable "a"').to.eql({ + value: '"a"', + type: 'string', + start: 232, + end: 235, + line: 13, + column: 4, + }); + expect(b, 'generate token for variable "b"').to.eql({ + value: '"b"', + type: 'string', + start: 247, + end: 250, + line: 14, + column: 8, + }); + expect(c, 'generate token for variable "c"').to.eql({ + value: '"c"', + type: 'string', + start: 324, + end: 327, + line: 18, + column: 16, + outputValue: { + value: 'string', + type: 'text', + start: 329, + end: 335, + line: 18, + column: 21, + }, + }); + expect(d, 'generate token for variable "d"').to.eql({ + value: '"d"', + type: 'string', + start: 353, + end: 356, + line: 19, + column: 16, + outputValue: { + value: 'string', + type: 'text', + start: 358, + end: 364, + line: 19, + column: 21, + }, + }); + }); }); diff --git a/packages/module-utils/test/generate-dts.spec.ts b/packages/module-utils/test/generate-dts.spec.ts index e9b367007..01ff0eb95 100644 --- a/packages/module-utils/test/generate-dts.spec.ts +++ b/packages/module-utils/test/generate-dts.spec.ts @@ -85,6 +85,36 @@ describe('Generate DTS', function () { expect(tk.typecheck('test.ts')).to.equal(''); }); + it('should generate complex Stylable var .d.ts', () => { + tk.populate({ + 'test.st.css': ` + :vars { + a: st-map( + b st-array(red, blue, st-map(d green)), + c gold, + ) + } + `, + 'test.ts': ` + import { eq } from "./test-kit"; + import { stVars } from "./test.st.css"; + + eq<{ + b: [ + string, + string, + { + d: string; + } + ]; + c: string; + }>(stVars.a); + `, + }); + + expect(tk.typecheck('test.ts')).to.equal(''); + }); + it('should warn about non-existing Stylable var', () => { tk.populate({ 'test.st.css': ':vars {c1: green;}', diff --git a/packages/module-utils/test/sourcemap.spec.ts b/packages/module-utils/test/sourcemap.spec.ts index c3d1cbd90..1505e23d7 100644 --- a/packages/module-utils/test/sourcemap.spec.ts +++ b/packages/module-utils/test/sourcemap.spec.ts @@ -371,4 +371,76 @@ describe('.d.ts source-maps', () => { name: null, }); }); + + it('maps a complex st-vars example to its positions in the original ".st.css" file', async () => { + const res = generateStylableResult({ + entry: `/entry.st.css`, + files: { + '/entry.st.css': { + namespace: 'entry', + content: deindent(` + :vars { + a: st-map(b st-array(red, + st-map(e blue), + st-map(d green)), + c gold, + ) + } + `), + }, + }, + }); + + const dtsText = generateDTSContent(res); + const sourcemapText = generateDTSSourceMap(dtsText, res.meta); + + sourceMapConsumer = await new SourceMapConsumer(sourcemapText); + + const aOriginalPosition = sourceMapConsumer.originalPositionFor( + getPosition(dtsText, 'a":') + ); + const bOriginalPosition = sourceMapConsumer.originalPositionFor( + getPosition(dtsText, 'b":') + ); + const cOriginalPosition = sourceMapConsumer.originalPositionFor( + getPosition(dtsText, 'c":') + ); + const dOriginalPosition = sourceMapConsumer.originalPositionFor( + getPosition(dtsText, 'd":') + ); + const eOriginalPosition = sourceMapConsumer.originalPositionFor( + getPosition(dtsText, 'e":') + ); + + expect(aOriginalPosition).to.eql({ + column: 4, + line: 3, + name: null, + source: 'entry.st.css', + }); + expect(bOriginalPosition).to.eql({ + column: 14, + line: 3, + name: null, + source: 'entry.st.css', + }); + expect(cOriginalPosition).to.eql({ + column: 6, + line: 6, + name: null, + source: 'entry.st.css', + }); + expect(dOriginalPosition).to.eql({ + column: 15, + line: 5, + name: null, + source: 'entry.st.css', + }); + expect(eOriginalPosition).to.eql({ + column: 15, + line: 4, + name: null, + source: 'entry.st.css', + }); + }); }); diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 636b160d1..2316f77d3 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -19,11 +19,13 @@ export interface InheritedAttributes { [props: string]: any; } +type StVar = string | { [key: string]: StVar } | StVar[]; + export interface StylableExports { classes: ClassesMap; keyframes: Record; + stVars: Record; vars: Record; - stVars: Record; } export type STFunction = (