import { AnalysisImpl as Analysis , AttrValue , Block , BlockClass , ElementAnalysis , ResolvedConfiguration as CSSBlocksConfiguration , charInFile , isNamespaceReserved , } from "@css-blocks/core" ; import { AST , print } from "@glimmer/syntax" ; import { SourceLocation , SourcePosition } from "@opticss/element-analysis" ; import { TemplateTypes } from "@opticss/template-api" ; import { assertNever } from "@opticss/util" ; import * as debugGenerator from "debug" ; import { getEmberBuiltInStates , isEmberBuiltIn , isEmberBuiltInNode } from "./EmberBuiltins" ; import { AnalyzableNode , AnalyzableProperty , cssBlockError , isAnalyzableProperty , isAttrNode , isBooleanLiteral , isConcatStatement , isElementNode , isHashPair , isMustacheStatement , isNullLiteral , isNumberLiteral , isPathExpression , isStringLiteral , isSubExpression , isTextNode , isUndefinedLiteral , pathString , } from "./utils" ; export type TernaryExpression = AST . Expression | AST . MustacheStatement | null ; export type StringExpression = AST . MustacheStatement | AST . ConcatStatement | AST . SubExpression | AST . PathExpression | null ; export type BooleanExpression = AST . Expression | AST . MustacheStatement ; export type TemplateElement = ElementAnalysis < BooleanExpression , StringExpression , TernaryExpression > ; export type AttrRewriteMap = { [ key ] : TemplateElement ; } ; const NAMESPACED_ATTR = /^([^:]+):([^:]+)$/ ; const STYLE_IF = "style-if" ; const STYLE_UNLESS = "style-unless" ; const DEFAULT_BLOCK_NAME = "default" ; const DEFAULT_BLOCK_NS = "block" ; const debug = debugGenerator ( "css-blocks:ember:element-analyzer" ) ; export function isStyleOfHelper ( node ) { if ( ! ( isMustacheStatement ( node ) || isSubExpression ( node ) ) ) return false ; if ( isPathExpression ( node . path ) || isStringLiteral ( node . path ) ) { return node . path . original === "style-of" ; } else { return false ; } } export function isAnalyzedHelper ( node ) { if ( isElementNode ( node ) ) return false ; return isEmberBuiltInNode ( node ) || isStyleOfHelper ( node ) ; } interface AnalyzableScope { type : "scope" ; namespace : string ; property : AnalyzableProperty ; } interface AnalyzableClass { type : "class" ; namespace : string ; property : Exclude < AnalyzableProperty , AST . PathExpression > ; } interface AnalyzableState { type : "state" ; namespace : string ; name : string ; property : AnalyzableProperty ; } type AnalyzableAttribute = AnalyzableScope | AnalyzableClass | AnalyzableState ; enum AnalysisMode { ANALYZE = 1 , REWRITE = 2 , ANALYZE_AND_REWRITE = ANALYZE | REWRITE } export class ElementAnalyzer < TemplateType extends keyof TemplateTypes > { analysis : Analysis < TemplateType > ; block : Block ; template : TemplateTypes [ TemplateType ] & { identifier : string ; path ? : string ; relativePath ? : string ; fullPath ? : string ; } ; cssBlocksOpts : CSSBlocksConfiguration ; reservedClassNames : Set < string > ; templatePath : string ; constructor ( analysis , cssBlocksOpts ) { this . analysis = analysis ; this . block = analysis . getBlock ( DEFAULT_BLOCK_NAME ) ! ; this . template = analysis . template ; this . templatePath = this . template . path || this . template . relativePath || this . template . fullPath || this . template . identifier ; this . cssBlocksOpts = cssBlocksOpts ; this . reservedClassNames = analysis . reservedClassNames ( ) ; } analyze ( node , atRootElement , forbidNonBlockAttributes = false ) { return this . _analyze ( node , atRootElement , AnalysisMode . ANALYZE , forbidNonBlockAttributes ) ; } reanalyze ( node , atRootElement ) { return this . _analyze ( node , atRootElement , AnalysisMode . REWRITE ) ; } analyzeForRewrite ( node , atRootElement , forbidNonBlockAttributes = false ) { return this . _analyze ( node , atRootElement , AnalysisMode . ANALYZE_AND_REWRITE , forbidNonBlockAttributes ) ; } private debugAnalysis ( node , atRootElement , element ) { if ( ! debug . enabled ) return ; let startTag = "" ; if ( isElementNode ( node ) ) { startTag = `<${ node . tag } ${ node . attributes . map ( a => a . name ) . join ( " " ) }>` ; debug ( `Element ${ startTag } is ${ atRootElement ? "the root " : "a sub" }element at ${ this . debugTemplateLocation ( node ) }` ) ; } else { let pathName = ( isPathExpression ( node . path ) || isStringLiteral ( node . path ) ) ? node . path . original : "???" ; startTag = `{{${ pathName } ${ node . params . map ( a => print ( a ) ) . join ( " " ) } ${ node . hash . pairs . map ( ( h ) => print ( h ) ) . join ( " " ) }}}` ; debug ( `Component ${ startTag } is ${ atRootElement ? "the root " : "a sub" }element at ${ this . debugTemplateLocation ( node ) }` ) ; } debug ( `↳ Analyzed as: ${ element . forOptimizer ( this . cssBlocksOpts ) [ 0 ] . toString ( ) }` ) ; } private debugTemplateLocation ( node ) { let templatePath = this . cssBlocksOpts . importer . debugIdentifier ( this . template . identifier , this . cssBlocksOpts ) ; return charInFile ( templatePath , node . loc . start ) ; } private debugBlockPath ( block = null ) { return this . cssBlocksOpts . importer . debugIdentifier ( ( block || this . block ) . identifier , this . cssBlocksOpts ) ; } private newElement ( node , mode ) { let label = isElementNode ( node ) ? node . tag : pathString ( node ) || undefined ; if ( mode & AnalysisMode . ANALYZE ) { return this . analysis . startElement < BooleanExpression , StringExpression , TernaryExpression > ( nodeLocation ( node ) , label ) ; } else { return new ElementAnalysis < BooleanExpression , StringExpression , TernaryExpression > ( nodeLocation ( node ) , this . reservedClassNames , label ) ; } } private finishElement ( node , element , mode ) { element . seal ( ) ; if ( mode & AnalysisMode . ANALYZE ) { this . analysis . endElement ( element , node . loc . end ) ; } } isAttributeAnalyzed ( attributeName ) { if ( NAMESPACED_ATTR . test ( attributeName ) ) { let namespace = RegExp . $1 ; let attrName = RegExp . $2 ; if ( isNamespaceReserved ( namespace ) ) { return [ null , null ] ; } else { return [ namespace , attrName ] ; } } else { return [ null , null ] ; } } private _assertClassAttributeValue ( node , property ) { if ( isPathExpression ( property ) ) { const name = this . _getAnalyzableAttributeName ( property ) ; throw cssBlockError ( `The ${ name } attribute must contain a value and is not allowed to be purely positional. Did you mean ${ name }="foo"?` , node , this . templatePath ) ; } return true ; } private _getAnalyzableAttributeName ( attribute ) { if ( isAttrNode ( attribute ) ) { return attribute . name ; } else if ( isHashPair ( attribute ) ) { return attribute . key ; } else if ( isPathExpression ( attribute ) ) { return attribute . original ; } else { assertNever ( attribute ) ; } } * eachAnalyzedAttribute ( node , forbidNonBlockAttributes = false ) { const propertyList = [ ] ; if ( isElementNode ( node ) ) { propertyList . push ( node . attributes ) ; } else { propertyList . push ( node . params , node . hash . pairs ) ; } for ( let list of propertyList ) { for ( let property of list ) { if ( isAnalyzableProperty ( property ) ) { const name = this . _getAnalyzableAttributeName ( property ) ; if ( name ) { const [ namespace , attrName ] = this . isAttributeAnalyzed ( name ) ; if ( namespace && attrName ) { if ( attrName === "class" ) { if ( this . _assertClassAttributeValue ( node , property ) ) { yield { type : "class" , namespace , property } ; } } else if ( attrName === "scope" ) { yield { type : "scope" , namespace , property } ; } else { yield { type : "state" , namespace , name : attrName , property } ; } } else { if ( forbidNonBlockAttributes ) { throw cssBlockError ( `An attribute without a block namespace is forbidden in this context: ${ name }` , node , this . templatePath ) ; } } } } } } } private _analyze ( node , atRootElement , mode , forbidNonBlockAttributes = false ) { const attrRewrites = { } ; let element = attrRewrites [ "class" ] = this . newElement ( node , mode ) ; let explicitScope = false ; for ( let analyzableAttr of this . eachAnalyzedAttribute ( node , forbidNonBlockAttributes ) ) { if ( analyzableAttr . type === "class" ) { this . processClass ( analyzableAttr . namespace , analyzableAttr . property , element , mode ) ; } else if ( analyzableAttr . type === "scope" ) { explicitScope = analyzableAttr . namespace === DEFAULT_BLOCK_NS ; this . processScope ( analyzableAttr . namespace , analyzableAttr . property , element , mode ) ; } } if ( atRootElement && ! explicitScope ) { element . addStaticClass ( this . block . rootClass ) ; } if ( isElementNode ( node ) ) { for ( let attribute of node . attributes ) { if ( attribute . name === "class" ) { throw cssBlockError ( `The class attribute is forbidden. Did you mean block:class?` , node , this . templatePath ) ; } } } for ( let analyzableAttr of this . eachAnalyzedAttribute ( node ) ) { if ( analyzableAttr . type === "state" ) { this . processState ( analyzableAttr . namespace , analyzableAttr . name , analyzableAttr . property , element , mode ) ; } } this . finishElement ( node , element , mode ) ; if ( ! isElementNode ( node ) && isEmberBuiltInNode ( node ) ) { this . debugAnalysis ( node , atRootElement , element ) ; let builtInName = pathString ( node ) ; if ( ! isEmberBuiltIn ( builtInName ) ) throw cssBlockError ( "[internal error]" , node , this . templatePath ) ; let klasses = [ ... element . classesFound ( ) ] ; const attrToState = getEmberBuiltInStates ( builtInName ) ; for ( let attrName of Object . keys ( attrToState ) ) { const stateName = attrToState [ attrName ] ; let element ; for ( let style of klasses ) { let attr = style . resolveAttribute ( stateName ) ; if ( ! attr || ! attr . presenceRule ) { continue ; } if ( ! element ) { element = this . newElement ( node , mode ) ; } attrRewrites [ attrName ] = element ; if ( mode & AnalysisMode . ANALYZE ) { element . addStaticClass ( style ) ; } element . addStaticAttr ( style , attr . presenceRule ) ; } if ( element ) { this . finishElement ( node , element , mode ) ; } } } this . debugAnalysis ( node , atRootElement , element ) ; return attrRewrites ; } private lookupClasses ( namespace , classes , node ) { let classNames = classes . trim ( ) . split ( /\s+/ ) ; let found = new Array < BlockClass > ( ) ; for ( let name of classNames ) { found . push ( this . lookupClass ( namespace , name , node ) ) ; } return found ; } private lookupBlock ( namespace , node ) { let block = ( namespace === DEFAULT_BLOCK_NS ) ? this . block : this . block . getExportedBlock ( namespace ) ; if ( block === null ) { throw cssBlockError ( `No block '${ namespace }' is exported from ${ this . debugBlockPath ( ) }` , node , this . templatePath ) ; } return block ; } private lookupClass ( namespace , name , node ) { let block = this . lookupBlock ( namespace , node ) ; let found = block . resolveClass ( name ) ; if ( found === null ) { throw cssBlockError ( `No class '${ name }' was found in block at ${ this . debugBlockPath ( block ) }` , node , this . templatePath ) ; } return found ; } /**
     * Adds blocks and block classes to the current node from the class attribute.
     * As class is not allowed to be positional it will never be a PathExpression
     * so we exclude that from the node type
     */ private processClass ( namespace , node , element , mode ) { let statements ; if ( isConcatStatement ( node . value ) ) { statements = node . value . parts ; } else { statements = [ node . value ] ; } for ( let statement of statements ) { if ( isTextNode ( statement ) || isStringLiteral ( statement ) ) { let value = isTextNode ( statement ) ? statement . chars : statement . value ; for ( let container of this . lookupClasses ( namespace , value , statement ) ) { element . addStaticClass ( container ) ; } } else if ( isMustacheStatement ( statement ) || isSubExpression ( statement ) ) { let helperType = isStyleIfHelper ( statement ) ; if ( helperType ) { let condition = statement . params [ 0 ] ; let whenTrue = [ ] ; let whenFalse = [ ] ; let mainBranch = statement . params [ 1 ] ; let elseBranch = statement . params [ 2 ] ; if ( isStringLiteral ( mainBranch ) ) { let containers = this . lookupClasses ( namespace , mainBranch . value , mainBranch ) ; if ( helperType === "style-if" ) { whenTrue = containers ; } else { whenFalse = containers ; } } else { throw cssBlockError ( `{{${ helperType }}} expects a string literal as its second argument.` , mainBranch , this . templatePath ) ; } if ( elseBranch ) { if ( isStringLiteral ( elseBranch ) ) { let containers = this . lookupClasses ( namespace , elseBranch . value , elseBranch ) ; if ( helperType === "style-if" ) { whenFalse = containers ; } else { whenTrue = containers ; } } else { throw cssBlockError ( `{{${ helperType }}} expects a string literal as its third argument.` , elseBranch , this . templatePath ) ; } } if ( mode & AnalysisMode . REWRITE ) { element . addDynamicClasses ( { condition , whenTrue , whenFalse } ) ; } else { element . addDynamicClasses ( { condition : null , whenTrue , whenFalse } ) ; } } else { throw cssBlockError ( `Only {{style-if}} or {{style-unless}} helpers are allowed in class attributes.` , node , this . templatePath ) ; } } else { throw cssBlockError ( `Only string literals, {{style-if}} or {{style-unless}} are allowed in class attributes.` , node , this . templatePath ) ; } } } private processScope ( namespace , node , element , _mode ) { let block = this . lookupBlock ( namespace , node ) ; if ( isPathExpression ( node ) ) { element . addStaticClass ( block . rootClass ) ; } else if ( isTextNode ( node . value ) ) { if ( node . value . chars === "" ) { element . addStaticClass ( block . rootClass ) ; } else { throw cssBlockError ( "String literal values are not allowed for the scope attribute" , node , this . templatePath ) ; } } else if ( isBooleanLiteral ( node . value ) ) { if ( node . value . value ) { element . addStaticClass ( block . rootClass ) ; } } else if ( isMustacheStatement ( node . value ) || isSubExpression ( node . value ) ) { element . addDynamicClasses ( { condition : node . value , whenTrue : [ block . rootClass ] , whenFalse : [ ] , } ) ; } } /**
     * Adds states to the current node.
     */ private processState ( blockName , stateName , node , element , mode ) { let stateBlock = this . lookupBlock ( blockName , node ) ; let containers = element . classesForBlock ( stateBlock ) ; if ( containers . length === 0 ) { throw cssBlockError ( `No block or class from ${ blockName || "the default block" } is assigned to the element so a state from that block cannot be used.` , node , this . templatePath ) ; } let staticSubStateName = undefined ; let dynamicSubState = undefined ; if ( isPathExpression ( node ) ) { } else if ( isTextNode ( node . value ) ) { staticSubStateName = node . value . chars ; if ( staticSubStateName === "" ) { staticSubStateName = undefined ; } } else if ( isStringLiteral ( node . value ) ) { staticSubStateName = node . value . value ; if ( staticSubStateName === "" ) { staticSubStateName = undefined ; } } else if ( isNumberLiteral ( node . value ) ) { staticSubStateName = node . value . value . toString ( ) ; if ( staticSubStateName === "" ) { staticSubStateName = undefined ; } } else if ( isBooleanLiteral ( node . value ) ) { if ( ! node . value . value ) { return ; } } else if ( isMustacheStatement ( node . value ) || isConcatStatement ( node . value ) || isSubExpression ( node . value ) || isPathExpression ( node . value ) ) { dynamicSubState = node . value ; } else if ( isNullLiteral ( node . value ) || isUndefinedLiteral ( node . value ) ) { return ; } else { assertNever ( node . value ) ; } let found = false ; const errors = [ ] ; for ( let container of containers ) { let stateGroup = container . resolveAttribute ( { namespace : "state" , name : stateName , } ) ; let state = undefined ; if ( stateGroup && staticSubStateName ) { found = true ; state = stateGroup . resolveValue ( staticSubStateName ) ; if ( state ) { element . addStaticAttr ( container , state ) ; } else { throw cssBlockError ( `No sub-state found named ${ staticSubStateName } in state ${ stateName } for ${ container . asSource ( ) } in ${ blockName || "the default block" }.` , node , this . templatePath ) ; } } else if ( stateGroup ) { if ( stateGroup . hasResolvedValues ( ) ) { found = true ; if ( dynamicSubState ) { if ( mode & AnalysisMode . REWRITE ) { element . addDynamicGroup ( container , stateGroup , dynamicSubState ) ; } else { element . addDynamicGroup ( container , stateGroup , null ) ; } } else { throw cssBlockError ( `No sub-state specified for ${ stateName } for ${ container . asSource ( ) } in ${ blockName || "the default block" }.` , node , this . templatePath ) ; } } else { found = true ; if ( dynamicSubState ) { if ( dynamicSubState . type === "ConcatStatement" ) { throw cssBlockError ( `The dynamic statement for a boolean state must be set to a mustache statement with no additional text surrounding it.` , dynamicSubState , this . templatePath ) ; } let state = stateGroup . presenceRule ; element . addDynamicAttr ( container , state ! , dynamicSubState ) ; } else { element . addStaticAttr ( container , stateGroup . presenceRule ! ) ; } } } else { if ( staticSubStateName ) { errors . push ( [ `No state found named ${ stateName } with a sub-state of ${ staticSubStateName } for ${ container . asSource ( ) } in ${ blockName || "the default block" }.` , node , this . templatePath ] ) ; } else { errors . push ( [ `No state(s) found named ${ stateName } for ${ container . asSource ( ) } in ${ blockName === "block" && "the default block" || blockName }.` , node , this . templatePath ] ) ; } } } if ( ! found ) { throw cssBlockError ( ... errors [ 0 ] ) ; } } } function isStyleIfHelper ( node ) { if ( node . path . type !== "PathExpression" ) { return undefined ; } let parts = ( node . path ) . parts ; if ( parts . length > 0 ) { let name = parts [ 0 ] ; if ( name === STYLE_IF || name === STYLE_UNLESS ) { return name ; } else { return undefined ; } } else { return undefined ; } } function nodeLocation ( node ) { let start = { filename : node . loc . source || undefined , line : node . loc . start . line , column : node . loc . start . column , } ; let end = { filename : node . loc . source || undefined , line : node . loc . start . line , column : node . loc . start . column , } ; return { start , end } ; }