Source: lib/Attributes.js

import { ReactiveVar } from 'meteor/reactive-var'
import { Template } from 'meteor/templating'
import { BlazeUI } from '../BlazeUI'
import { Styles } from './Styles'
import { isFunction, isBoolean } from '../utils/types'
import { isCompatibleAttribute } from '../utils/isCompatibleAttribute'

/**
 * Global attributes resolution handler.
 * Hooks into a Template instance's lifecycle using
 * `onCreated` to register a reactive var that holds all element attributes.
 * Hooks into `onDestroyed` to dispose automatically.
 * Usually you don't have to manage this on your own.
 * 
 * @namespace
 */
export const Attributes = {}

/**
 * Registers a global attributes-resolver function for a
 * given {UIComponent.name}.
 * @private
 * @type {Map<string, function>}
 */
const attributesResolverRegistry = new Map()

/**
 * Registers global property ignore lists.
 * @type {Map<any, any>}
 */
const attributesIgnoreLists = new Map()

/**
 * Stores the attributes object (a {ReactiveVar}) for a given Template instance object.
 * @type {WeakMap<Blaze.TemplateInstance, { attributes: ReactiveVar, state: ReactiveDict}>}
 * @private
 */
const instanceAttributesRegistry = new WeakMap()

/**
 * Wraps a Template lifecycle function
 * @private
 * @param lifecycleFn {function} one of the three major lifecycle functions of a Blaze.Template (`onCreated`, `onRendered`, `onDestroyed`).
 * @param stateFactory {function?} optional function that is called to create/attach a ReactiveDict as state for this instance 
 * @param onAfterCallback {function?} optional function to execute after the lifecycle function was called
 * @return {function():void}
 * @see https://www.blazejs.org/api/templates
 */
const onInstanceLifecycleFunction = ({ lifecycleFn, stateFactory, onAfterCallback }) => function () {
  const instance = this
  const state = instance.state ?? (typeof stateFactory === 'function'
    ? stateFactory({ instance, api: BlazeUI })
    : undefined)
  lifecycleFn?.call(this, { instance, state })
  onAfterCallback?.({ instance, state })
}

/**
 * Registers a given component for attribute resolving
 * @param component {UIComponent}
 */
Attributes.register = (component) => {
  const { name, attributes, state, onCreated, onDestroyed, onRendered, events, helpers } = component
  const resolver = isFunction(attributes)
    ? attributes
    : defaultAttributes(component)

  attributesResolverRegistry.set(name, resolver)

  const template = Template[name]

  if (!template) {
    throw new Error(`Cannot find Template: ${name}`)
  }

  template.onCreated(onInstanceLifecycleFunction({
    lifecycleFn: onCreated,
    onAfterCallback: Attributes.create,
    stateFactory: state
  }))
  template.onDestroyed(onInstanceLifecycleFunction({
    lifecycleFn: onDestroyed,
    onAfterCallback: Attributes.destroy
  }))

  if (onRendered) {
    template.onRendered(onInstanceLifecycleFunction({
      lifecycleFn: onRendered
    }))
  }
  if (events) {
    template.events(events)
  }
  if (helpers) {
    template.helpers(helpers)
  }
}

/**
 * Creates a new observer for attribute and state changes
 * for a given instance and updates attributes accordingly.
 * You usually don't need to run this manually,
 * because instances are automatically registered for this
 * method.
 * @param instance {Blaze.TemplateInstance}
 * @param state {ReactiveDict}
 */
Attributes.create = ({ instance, state }) => {
  const attributes = new ReactiveVar({})
  instanceAttributesRegistry.set(instance, { attributes, state })

  const { resolver, filter } = getResolverBy(instance)
  const stateFacade = state ?? { all: () => {} }
  instance.autorun(() => {
    const data = Template.currentData() ?? {}
    const cleanData = filter(data)
    const stateVars = stateFacade.all() ?? {}
    const resolvedAttributes = resolver({
      props: cleanData ?? {},
      state: stateVars ?? {},
      api: BlazeUI,
      instance
    })

    if (resolvedAttributes === null) {
      attributes.clear()
    }
    else if (typeof resolvedAttributes !== 'undefined') {
      attributes.set(resolvedAttributes)
    }
  })
}

/**
 * Registers a list of props to filter out, before passing them
 * on to the resolver function.
 * This is esepcially useful, if you use tools that inject
 * data on a global level into templates.
 * @param name {string|null} name of the component, needs to be registered. Set to null for global filter.
 * @param fn {function|null} function to filter props by name, array filter callback: `name => Boolean`
 * @example
 * // ignore all attributes from FlowRouter
 * BlazeUI.attributes().filter({ name: null, fn: n => n !== 'params' && n !== 'queryParams' })
 */
Attributes.filter = ({ name, fn }) => {
  const key = name === null ? '__global__' : name
  const del = fn === null

  if (del) {
    attributesIgnoreLists.delete(key)
  }
  else {
    attributesIgnoreLists.set(key, fn)
  }
  console.debug(attributesIgnoreLists)
}

/**
 * Cleanup fn to be run when the instance is destroyed.
 * You usually don't need to run this manually,
 * because instances are automatically registered for this
 * method.
 * @param instance {Blaze.TemplateInstance}
 */
Attributes.destroy = ({ instance }) => {
  instanceAttributesRegistry.delete(instance)
}

/**
 * Finds the registered attributes resolver function by a given template
 * @private
 * @param instance {Blaze.TemplateInstance}
 * @return {{resolver: Function, name: *}}
 */
const getResolverBy = (instance) => {
  const name = instance?.view?.name?.replace('Template.', '')
  if (!name) throw new Error(`Instance has no name: ${instance}`)

  const resolver = attributesResolverRegistry.get(name)
  if (!resolver) throw new Error(`Attributes resolver not registered for: ${name}`)

  const globalFilter = attributesIgnoreLists.get('__global__') ?? (() => {})
  const localFilter = attributesIgnoreLists.get(name) ?? (() => {})

  // filter ruleset is hierarchichal:
  // Component-scoped (local) has highest prio
  // Then global filters
  // If none exist, props are allowed by default
  const byRules = name => {
    const localValue = localFilter(name)
    if (isBoolean(localValue)) { return localValue }
    const globalValue = globalFilter(name)
    if (isBoolean(globalValue)) { return globalValue }
    return true
  }
  const filter = props => {
    if (!props) return props
    const copy = {}
    Object.keys(props).filter(byRules).forEach(key => {
      copy[key] = props[key]
    })
    return copy
  }
  return { name, resolver, filter }
}


/**
 * @private
 * @param ctx
 * @return {{(Object): Object, (Object): Object}}
 */
export const defaultAttributes = (ctx) => {
  return ctx.variants
    ? variantAttributes(ctx)
    : plainAttributes(ctx)
}

/**
 * Components without variants have a simple, plain class structure.
 * @private
 * @param ctx {UIComponent}
 * @return {function(object):object}
 */
const plainAttributes = ctx => ({ props: { class: className, ...rest } }) => ({
  ...ctx.attributes,
  class: Styles.merge(ctx.class, className),
  ...compatibleAttributes(rest)
})

/**
 * Components with variants need to be aware, that
 * variants can be dynamically added or changed.
 * @private
 * @param ctx {UIComponent}
 * @return {function(object):object}
 */
const variantAttributes = ctx => ({ props }) => {
  const { options, className, rest } = Styles.extract(ctx, props)
  return {
    ...ctx.attributes,
    class: Styles.get({ ctx, options, classNames: [className] }),
    ...compatibleAttributes(rest)
  }
}

/**
 * Creates a copy of an object with properties, compatible
 * with a DOM elements' attributes.
 * Non-mutating.
 * @private
 * @param obj {object}
 * @return {object}
 */
export const compatibleAttributes = obj => {
  const tmp = {}
  Object.entries(obj).forEach(([key, value]) => {
    if (!isCompatibleAttribute(key, value)) return
    tmp[key] = String(value)
  })
  return tmp
}

/**
 * The implementation of the global `blazeui_atts` helper.
 * @function
 * @private
 * @return {object|undefined}
 */
export const blazeUIAtts = function () {
  const instance = Template.instance()
  const current = instanceAttributesRegistry.get(instance)
  if (current?.attributes) {
    return current?.attributes.get()
  }
}

/** @private */
Template.registerHelper('blazeui_atts', blazeUIAtts)