require 'archetype/functions/helpers' require 'archetype/functions/styleguide_memoizer' require 'thread' # # This is the magic of Archetype. This module provides the interfaces for constructing, # extending, and retrieving reusable UI components # module Archetype::SassExtensions::Styleguide # :stopdoc: INHERIT = 'inherit' STYLEGUIDE = 'styleguide' DROP = 'drop' DEFAULT = 'default' REGEX = 'regex' SPECIAL = %w(states selectors) # these are unique CSS keys that can be exploited to provide fallback functionality by providing a second value # e.g color: red; color: rgba(255,0,0, 0.8); FALLBACKS = %w(background background-image background-color border border-bottom border-bottom-color border-color border-left border-left-color border-right border-right-color border-top border-top-color clip color layer-background-color outline outline-color white-space) ADDITIVES = FALLBACKS + [DROP, INHERIT, STYLEGUIDE] @@archetype_styleguide_mutex = Mutex.new # :startdoc: # # interface for adding new components to the styleguide structure # # *Parameters*: # - $id {String} the component identifier # - $data {List} the component data object # - $default {List} the default data object (for extending) # - $theme {String} the theme to insert the component into # - $force {Boolean} if true, forcibly insert the component # *Returns*: # - {Boolean} whether or not the component was added # def styleguide_add_component(id, data, default = nil, theme = nil, force = false) @@archetype_styleguide_mutex.synchronize do theme = get_theme(theme) components = theme[:components] id = helpers.to_str(id) # if force was true, we have to invalidate the memoizer memoizer.clear(theme[:name]) if force # if we already have the component, don't create it again return Sass::Script::Bool.new(false) if component_exists(id, theme, nil, force) # otherwise add it components[id] = helpers.list_to_hash(default, 1, SPECIAL, ADDITIVES).merge(helpers.list_to_hash(data, 1, SPECIAL, ADDITIVES)) return Sass::Script::Bool.new(true) end end Sass::Script::Functions.declare :styleguide_add_component, [:id, :data] Sass::Script::Functions.declare :styleguide_add_component, [:id, :data, :default] Sass::Script::Functions.declare :styleguide_add_component, [:id, :data, :default, :theme] # # interface for extending an existing components in the styleguide structure # # *Parameters*: # - $id {String} the component identifier # - $data {List} the component data object # - $theme {String} the theme to insert the component into # - $extension {String} the name of the extension # - $force {Boolean} if true, forcibly extend the component # *Returns*: # - {Boolean} whether or not the component was extended # def styleguide_extend_component(id, data, theme = nil, extension = nil, force = false) @@archetype_styleguide_mutex.synchronize do theme = get_theme(theme) components = theme[:components] id = helpers.to_str(id) # if force was set, we'll create a random token for the name extension = rand(36**8).to_s(36) if force # convert the extension into a hash (if we don't have an extension, compose one out of its data) extension = helpers.to_str(extension || data).hash extensions = theme[:extensions] return Sass::Script::Bool.new(false) if component_exists(id, theme, extension, force) extensions.push(extension) components[id] = (components[id] ||= {}).rmerge(helpers.list_to_hash(data, 1, SPECIAL, ADDITIVES)) return Sass::Script::Bool.new(true) end end Sass::Script::Functions.declare :styleguide_extend_component, [:id, :data] Sass::Script::Functions.declare :styleguide_extend_component, [:id, :data, :theme] Sass::Script::Functions.declare :styleguide_extend_component, [:id, :data, :theme, :extension] # # check whether or not a component (or a component extension) has already been defined # # *Parameters*: # - $id {String} the component identifier # - $data {List} the component data object # - $theme {String} the theme to insert the component into # - $extension {String} the name of the extension # - $force {Boolean} if true, forcibly extend the component # *Returns*: # - {Boolean} whether or not the component/extension exists # def styleguide_component_exists(id, theme = nil, extension = nil, force = false) @@archetype_styleguide_mutex.synchronize do extension = helpers.to_str(extension) if not extension.nil? return Sass::Script::Bool.new( component_exists(id, theme, extension, force) ) end end Sass::Script::Functions.declare :styleguide_extend_component, [:id] Sass::Script::Functions.declare :styleguide_extend_component, [:id, :theme] Sass::Script::Functions.declare :styleguide_extend_component, [:id, :theme, :extension] Sass::Script::Functions.declare :styleguide_extend_component, [:id, :theme, :extension, :force] # # given a description of the component, convert it into CSS # # *Parameters*: # - $description {String|List} the description of the component # - $theme {String} the theme to use # *Returns*: # - {List} a key-value paired list of styles # def styleguide(description, state = 'false', theme = nil) @@archetype_styleguide_mutex.synchronize do # convert it back to a Sass:List and carry on return helpers.hash_to_list(get_styles(description, theme, state), 0) end end # # output the CSS differences between components # # *Parameters*: # - $original {String|List} the description of the original component # - $other {String|List} the description of the new component # - $theme {String} the theme to use # *Returns*: # - {List} a key-value paired list of styles # def styleguide_diff(original, other, theme = nil) @@archetype_styleguide_mutex.synchronize do original = get_styles(original, theme) other = get_styles(other, theme) diff = original.diff(other) return helpers.hash_to_list(diff, 0) end end private def helpers @helpers ||= Archetype::Functions::Helpers end def memoizer Archetype::Functions::StyleguideMemoizer end # # given a sentence, deconstruct it into it's identifier and verbages # # *Parameters*: # - sentence {String|List} the sentence describing the component # - theme {String} the theme to use # - state {String} the name of a state to return # *Returns*: # - {Array} an array containing the identifer, modifiers, and a token # def grammar(sentence, theme = nil, state = 'false') theme = get_theme(theme) components = theme[:components] # get a list of valid ids styleguideIds = components.keys sentence = sentence.split if sentence.is_a? String sentence = sentence.to_a id = nil modifiers = [] if not sentence.empty? prefix = '' order = '' # these define various attributes for modifiers (e.g. `button with a shadow`) extras = %w(on with without) # these are things that are useless to us, so we just leave them out ignore = %w(a an also the this that is was it) # these are our context switches (e.g. `headline in a button`) contexts = %w(in) sentence.each do |item| item = item.value # find the ID if id.nil? and styleguideIds.include?(item) and prefix.empty? and order.empty? id = item # if it's a `context`, we need to increase the depth and reset the prefix elsif contexts.include?(item) order = "#{item}-#{order}" prefix = '' # if it's an `extra`, we update the prefix elsif extras.include?(item) prefix = "#{item}-" # finally, check that it's not on the ignore (useless) list. if it is, we just skip over it # (maybe this should be the first thing we check?) elsif not ignore.include?(item) modifiers.push("#{order}#{prefix}#{item}") end end end # if there was no id, return a list of valid IDs for reporting modifiers = styleguideIds if id.nil? # get the list of currenty installed component extensions extensions = theme[:extensions] if not id.nil? # TODO - low - eoneill: make sure we always want to return unique modifiers # i can't think of a case where we wouldn't want to remove dups # maybe in the case where we're looking for strict keys on the lookup? modifiers = modifiers.uniq token = memoizer.tokenize(theme[:name], extensions, id, modifiers, state) return id, modifiers, token end # # interface for extracting styles in the styleguide references # # *Parameters*: # - id {String} the component identifier # - modifiers {Array} the component modifiers # - strict {Boolean} is it a strict lookup? # - theme {String} the theme to use # - context {Hash} the context to work in # *Returns*: # - {Hash} a hash of the extracted styles # def extract_styles(id, modifiers, strict = false, theme = nil, context = nil) theme = get_theme(theme) context ||= theme[:components][id] || {} modifiers = helpers.to_str(modifiers) return {} if context.nil? or context.empty? # push on the defaults first out = (strict ? resolve_dependents(id, context[modifiers], theme[:name], context) : context[DEFAULT]) || {} out = out.clone # if it's not strict, find anything that matched if not strict modifiers = modifiers.split context.each do |definition| modifier = definition[0] if modifier != DEFAULT match = true modifier = modifier.split if modifier[0] == REGEX # if it's a regex pattern, test if it matches match = modifiers.join(' ') =~ /#{modifiers[1].gsub(/\A"|"\Z/, '')}/i else # otherwise, if the modifier isn't in our list of modifiers, it's not valid and just move on modifier.each { |i| match = false if not modifiers.include?(i) } end # if it matched, process it out = out.rmerge(resolve_dependents(id, definition[1], theme[:name], nil, out.keys)) if match end end end # recompose the special keys and extract any nested/inherited styles # this lets us define special states and elements SPECIAL.each do |special_key| if out.is_a? Hash special = out[special_key] tmp = {} (special || {}).each { |key, value| tmp[key] = extract_styles(key, key, true, theme[:name], special) } out[special_key] = tmp if not tmp.empty? end end # check for nested styleguides styleguide = out[STYLEGUIDE] if styleguide and not styleguide.empty? styles = get_styles(styleguide, theme[:name]) out.delete(STYLEGUIDE) out = styles.rmerge(out) end return out end # # resolve any dependent references from the component # # *Parameters*: # - id {String} the component identifier # - value {Hash} the current value # - theme {String} the theme to use # - context {Hash} the context to work in # - keys {Array} list of the external keys # *Returns*: # - {Hash} a hash of the resolved styles # def resolve_dependents(id, value, theme = nil, context = nil, keys = nil) # we have to create a clone here as the passed in value is volatile and we're performing destructive changes value = value.clone # check that we're dealing with a hash if value.is_a?(Hash) # check for dropped styles drop = value[DROP] if not drop.nil? tmp = {} if %w(all true).include?(helpers.to_str(drop)) and not keys.nil? and not keys.empty? keys.each do |key| tmp[key] = 'nil' end else drop = drop.to_a drop.each do |key| tmp[helpers.to_str(key)] = 'nil' end end value.delete(DROP) value = tmp.rmerge(value) end # check for inheritance inherit = value[INHERIT] if inherit and not inherit.empty? # create a temporary object and extract the nested styles tmp = {} inherit.each { |related| tmp = tmp.rmerge(extract_styles(id, related, true, theme, context)) } # remove the inheritance key and update the styles value.delete(INHERIT) value = tmp.rmerge(value) end end # return whatever we got return value end # # keep a registry of styleguide themes # # *Parameters*: # - theme {String} the theme to use # *Returns*: # - {Hash} the theme # def get_theme(theme) theme_name = helpers.to_str(theme || 'archetype') @@styleguide_themes ||= {} theme = @@styleguide_themes[theme_name] ||= {} theme[:name] ||= theme_name theme[:components] ||= {} theme[:extensions] ||= [] return theme end # # driver method for converting a sentence into a list of styles # # *Parameters*: # - description {String|List} the description of the component # - theme {String} the theme to use # - state {String} the name of a state to return # *Returns*: # - {Hash} the styles # def get_styles(description, theme = nil, state = 'false') state = helpers.to_str(state) description = description.to_a styles = {} description.each do |sentence| # get the grammar from the sentence id, modifiers, token = grammar(sentence, theme, state) if id # check memoizer memoized = memoizer.fetch(theme, token) if memoized styles = styles.rmerge(memoized) else # fetch additional styles extracted = extract_styles(id, modifiers, false, theme) # we can delete anything that had a value of `nil` as we won't be outputting those extracted.delete_if { |k,v| helpers.is_value(v, :nil) } styles = styles.rmerge(extracted) memoizer.add(theme, token, extracted) end elsif not helpers.is_value(sentence, :nil) helpers.logger.record(:warning, "[archetype:styleguide:missing_identifier] `#{helpers.to_str(sentence)}` does not contain an identifier. please specify one of: #{modifiers.sort.join(', ')}") end end # now that we've collected all of our styles, if we requested a single state, merge that state upstream if state != 'false' and styles['states'] state = styles['states'][state] # remove any nested/special keys SPECIAL.each do |special| styles.delete(special) end styles = styles.merge(state) if not (state.nil? or state.empty?) end return styles end # # check whether or not a component (or a component extension) has already been defined # # *Parameters*: # - $id {String} the component identifier # - $data {List} the component data object # - $theme {String} the theme to insert the component into # - $extension {String} the name of the extension # - $force {Boolean} if true, forcibly extend the component # *Returns*: # - {Boolean} whether or not the component/extension exists # def component_exists(id, theme = nil, extension = nil, force = false) status = false theme = get_theme(theme) if not theme.is_a? Hash id = helpers.to_str(id) # determine the status of the component status = (extension.nil?) ? (not theme[:components][id].nil?) : theme[:extensions].include?(extension) return (status and not force and Compass.configuration.memoize) end end