UNPKG

apostrophe

Version:
785 lines (715 loc) • 23.1 kB
// Render a stylesheet from a given schema and doc. // Some basic rules: // - Do not mutate the schema or doc. // - Explicitly validate the schema, the renders don't do that // because of performance reasons. // - Reuse the response as much as possible as it is a relatively // expensive operation. /** * @typedef {Object} SchemaField * @property {string} name - The field name * @property {string} type - The field type (e.g., 'string', 'object', 'range') * @property {string|string[]} [selector] - CSS selector(s) for this field * @property {string|string[]} [property] - CSS property/properties to apply * @property {string} [unit] - Unit to append to value (e.g., 'px', 'em') * @property {string} [valueTemplate] - Template string with %VALUE% placeholder * @property {string} [mediaQuery] - Media query condition * @property {string} [class] - Class reference * @property {boolean} [important] - Whether to add !important flag * @property {SchemaField[]} [schema] - Optional subfields for some types * @property {Object} [if] - Conditional logic for field rendering */ /** * @typedef {Object} NormalizedField * @property {string[]} selectors - CSS selectors for this style * @property {string[]} properties - CSS properties to apply * @property {any} value - The field value to apply * @property {string} unit - The unit to append to the value * @property {SchemaField} raw - The original field object * @property {string} [valueTemplate] - Optional template for the value * @property {string} [mediaQuery] - Optional media query condition * @property {boolean} [important] - Whether to add !important flag */ /** * @typedef {Object} NormalizedObjectField * @property {string[]} selectors - CSS selectors for this style * @property {string[]} properties - CSS properties to apply * @property {any} value - The field value to apply * @property {SchemaField} raw - The original field object * @property {NormalizedField[]} subfields - Normalized subfields from object schema * @property {string} [valueTemplate] - Optional template for the value * @property {string} [mediaQuery] - Optional media query condition * @property {boolean} [important] - Whether to add !important flag */ /** * @typedef {Object} RuntimeStorage * @property {Set<string>} classes - Set of class names to be applied * @property {Map<string, Set<string>|Map<string, Set<string>>>} styles - Map * of selectors to CSS rules, or media query strings to nested selector maps * @property {Set<boolean>} [inlineVotes] - Set of boolean votes to determine * if styles should be inline (only used in scoped styles) */ import { klona } from 'klona'; import customRules from './customRules.mjs'; export default renderGlobalStyles; export { renderGlobalStyles, renderScopedStyles }; // Exported for testing purposes export const NORMALIZERS = { _: normalize, object: normalizeObject }; export const EXTRACTORS = { _: extract, object: extractObject }; const FILTERS = { _: filter, object: filterObject }; /** * Renders CSS stylesheet from a schema and document object. * * @param {SchemaField[]} schema - Array of field schema definitions * @param {Object} doc - Document containing field values * @param {Object} options - Rendering options * @param {Function} [options.checkIfConditionsFn] - Universal function to * evaluate field conditions * @returns {{ css: string; classes: string[] }} Compiled CSS stylesheet and classes */ function renderGlobalStyles(schema, doc, { checkIfConditionsFn } = {}) { const withConditions = filterConditionalFields( klona(schema), doc, { checkFn: checkIfConditionsFn } ); const storage = { classes: new Set(), styles: new Map(), conditions: withConditions.conditions }; for (const field of withConditions.schema) { const filter = FILTERS[field.type] || FILTERS._; if (!filter(field, doc)) { continue; } const normalizer = NORMALIZERS[field.type] || NORMALIZERS._; const extractor = EXTRACTORS[field.type] || EXTRACTORS._; const normalzied = normalizer(field, doc, { storage }); extractor(normalzied, storage); } return { css: stringifyRules(storage.styles), classes: [ ...storage.classes ] }; }; /** * Renders CSS stylesheet from a schema and document object, scoped * to a root selector or as inline styles. * * @param {SchemaField[]} schema - Array of field schema definitions * @param {Object} doc - Document containing field values * @param {Object} options - Rendering options * @param {string} [options.rootSelector] - Root selector to prepend to all selectors * @param {Function} [options.checkIfConditionsFn] - Universal function to * evaluate field conditions * @returns {{ css: string; classes: string[]; inline: string }} Compiled CSS * stylesheet, classes, and inline styles */ function renderScopedStyles(schema, doc, { rootSelector = null, checkIfConditionsFn, subset = null } = {}) { const withConditions = filterConditionalFields( klona(schema), doc, { checkFn: checkIfConditionsFn, subset } ); const storage = { classes: new Set(), styles: new Map(), inlineVotes: new Set(), conditions: withConditions.conditions }; for (const field of withConditions.schema) { const filter = FILTERS[field.type] || FILTERS._; if (!filter(field, doc)) { continue; } const normalizer = NORMALIZERS[field.type] || NORMALIZERS._; const extractor = EXTRACTORS[field.type] || EXTRACTORS._; const normalized = normalizer(field, doc, { rootSelector, storage }); extractor(normalized, storage); } const isInline = [ ...storage.inlineVotes ].every(vote => vote === true); if (isInline) { return { css: '', classes: [ ...storage.classes ], inline: stringifyRules(storage.styles, true) }; } return { css: stringifyRules(storage.styles), classes: [ ...storage.classes ], inline: '' }; }; /** * Filters schema fields based on conditional logic, removing fields * whose conditions evaluate to false. * * @param {SchemaField[]} schema - Array of field schema definitions * @param {Object} doc - Document containing field values * @param {Object} options * @param {Function} options.checkFn - Function to evaluate field conditions, * usually the universal core function * @param {string[]|null} [options.subset] - Optional subset of field names used * to reduce the original schema before evaluating conditions * @returns {{ conditions: Object<string, boolean>; schema: SchemaField[] }} * Object containing the evaluated conditions map and filtered schema */ function filterConditionalFields( schema, doc, { checkFn, subset } ) { const subsetSchema = Array.isArray(subset) ? schema.filter(field => subset.includes(field.name)) : schema; const conditions = getConditions( checkFn, subsetSchema, doc ); return { conditions, schema: subsetSchema.filter(field => { if (conditions[field.name] === false) { return false; } if (field.schema?.length > 0) { field.schema = field.schema.filter(subfield => { if (conditions[`${field.name}.${subfield.name}`] === false) { return false; } return true; }); if (field.schema.length === 0) { return false; } } return true; }) }; } /** * Evaluates conditional expressions for each field in the schema. * Supports one level of nesting for object fields with subfields. * Iterates until no condition changes are detected to support * conditions that depend on other conditions. * * @param {Function} checkIfConditions - The universal core function to evaluate * field conditions * @param {SchemaField[]} schema - Array of field schema definitions * @param {Object} doc - Document containing field values * @returns {Object<string, boolean>} Map of field names (or "field.subfield" * for nested fields) to their evaluated condition results */ function getConditions( checkIfConditions, schema, doc ) { const conditionType = 'if'; // Not a random number but the "sweet spot" for complex condition graphs // in a large schema while preventing infinite loops. const maxIterations = 20; // Simulate parent values const parentValues = Object.fromEntries( Object.entries(doc).map(([ key, value ]) => { return [ `<${key}`, value ]; }) ); const result = {}; let hasChanges = true; let iterations = 0; function voter(propName, conditionValue, docValue) { const name = propName.startsWith('<') ? propName.slice(1) : propName; if (result[name] === false) { return false; } } while (hasChanges && iterations < maxIterations) { hasChanges = false; iterations++; for (const field of schema) { if (field[conditionType]) { const newValue = checkIfConditions( doc, field[conditionType], voter ); if (result[field.name] !== newValue) { result[field.name] = newValue; hasChanges = true; } } if (field.schema?.length > 0) { for (const subfield of field.schema) { if (subfield[conditionType]) { const key = `${field.name}.${subfield.name}`; const newValue = checkIfConditions( { ...parentValues, ...doc[field.name] || {} }, subfield[conditionType], voter ); if (result[key] !== newValue) { result[key] = newValue; hasChanges = true; } } } } } } return result; } /** * For a given field (schema) and doc, determine if it should be processed * as a style field. * * @param {SchemaField} field * @param {Object} doc * @returns {NormalizedField} */ function filter(field, doc) { if (field.class) { return true; } if (!doc[field.name] && doc[field.name] !== 0) { return false; } const hasProperty = Array.isArray(field.property) ? field.property.length > 0 : !!field.property; const hasSelector = Array.isArray(field.selector) ? field.selector.length > 0 : !!field.selector; if (!hasProperty && !hasSelector) { return false; } return true; } /** * For a given object field (schema) and doc, determine if it should be processed * as an object style field. * * @param {SchemaField} field * @param {Object} doc * @returns {boolean} */ function filterObject(field, doc) { if (field.type !== 'object') { return false; } if (!Array.isArray(field.schema) || field.schema.length === 0) { return false; } if (!doc[field.name]) { return false; } if (field.property && field.valueTemplate) { return true; } return field.schema.some(subfield => { return filter(subfield, doc[field.name] || {}); }); } /** * Prepare a field for rendering by normalizing it to a standard structure. * * @param {SchemaField} field * @param {Object} doc * @param {Object} options * @param {String} options.rootSelector - Root selector from parent object field or * root scope. * @param {Boolean} [options.forceRoot] - Whether to force attach root selector * @param {String} [options.rootMediaQuery] - Media query from parent object field * @param {RuntimeStorage} [options.storage] * @returns {NormalizedField} */ function normalize(field, doc, { rootSelector, rootMediaQuery, forceRoot = false, storage } = {}) { let selectors = []; let properties = []; let fieldValue = doc[field.name]; let canBeInline = true; const fieldUnit = field.unit || ''; const fieldMediaQuery = field.mediaQuery || rootMediaQuery; if (field.class) { applyFieldClass(field.class, fieldValue, storage); return { raw: field, selectors, properties, value: fieldValue, unit: '' }; } if (!properties) { properties = []; } if (!fieldValue && fieldValue !== 0) { fieldValue = null; } if (typeof fieldValue === 'string' && fieldValue.startsWith('--')) { fieldValue = `var(${fieldValue})`; } if (Array.isArray(field.selector)) { selectors = field.selector; } else if (field.selector && (typeof field.selector) === 'string') { selectors = [ field.selector ]; } // This is a safe check, even when there is a root selector coming // from an object, because the object field itself will yield // a `false` here and thus force `inline: false` for the entire schema. if (selectors.length > 0) { canBeInline = false; } if (Array.isArray(field.property)) { properties = field.property; } else if (field.property && (typeof field.property) === 'string') { properties = [ field.property ]; } // Attach the root selector for non style fields ONLY by force if (rootSelector && typeof rootSelector === 'string') { rootSelector = [ rootSelector ]; } if (rootSelector?.length > 0) { const shouldAttachRoot = forceRoot || properties.length > 0; selectors = selectors.length > 0 ? selectors.map(s => rootSelector.map(r => `${r} ${s}`)).flat() : (shouldAttachRoot ? rootSelector : []); } if (field.mediaQuery) { canBeInline = false; } if (storage?.inlineVotes) { storage.inlineVotes.add(canBeInline); } return { raw: field, selectors, properties, value: fieldValue, unit: fieldUnit, ...field.valueTemplate && { valueTemplate: field.valueTemplate }, ...fieldMediaQuery && { mediaQuery: fieldMediaQuery }, ...field.important && { important: field.important } }; } /** * Prepare an object field for rendering by normalizing it to a standard structure. * * @param {SchemaField} field * @param {Object} doc * @param {Object} options * @param {String} options.rootSelector - Root selector from root scope. * @param {RuntimeStorage} options.storage * @returns {NormalizedObjectField} */ function normalizeObject(field, doc, { rootSelector, storage } = {}) { const subfields = []; const normalized = normalize(field, doc, { rootSelector, forceRoot: true, storage }); delete normalized.unit; const schema = field.schema.filter(subfield => { return filter(subfield, doc[field.name] || {}); }); for (const subfield of schema) { subfields.push( normalize( subfield, doc[field.name] || {}, { rootSelector: normalized.selectors, rootMediaQuery: normalized.mediaQuery, storage } ) ); } return { ...normalized, subfields }; } /** * Extract CSS rules from a normalized field and populate the central styles map * with the selectors and corresponding rules. * * @param {NormalizedField} normalized * @param {Object} doc * @param {RuntimeStorage} storage */ function extract(normalized, storage) { if (normalized.class) { return; } const styles = storage.styles; normalized.properties.forEach(property => { normalized.selectors.forEach(selector => { let currentStyles = styles; if (normalized.mediaQuery) { const mediaQuery = `@media ${normalized.mediaQuery}`; styles.set(mediaQuery, styles.get(mediaQuery) || new Map()); currentStyles = styles.get(mediaQuery); } let rule; if (customRules[normalized.raw.type]) { ({ field: normalized, rule } = customRules[normalized.raw.type]({ field: normalized, property })); } else { rule = `${property}: ${normalized.value}${normalized.unit}`; if (normalized.valueTemplate) { const value = interpolate( normalized.valueTemplate, normalized.value, { unit: normalized.unit, subfields: normalized.raw.schema, conditions: storage.conditions, fieldName: normalized.raw.name } ); if (!value) { return; } rule = `${property}: ${value}`; } } if (normalized.important) { rule += ' !important'; } currentStyles.set( selector, (currentStyles.get(selector) || new Set()).add(rule) ); }); }); } /** * Extract CSS rules from a normalized object field and populate the central * styles map with the selectors and corresponding rules. * * @param {NormalizedObjectField} normalized * @param {Object} doc * @param {RuntimeStorage} storage */ function extractObject(normalized, storage) { if (normalized.valueTemplate) { extract(normalized, storage); } normalized.subfields.forEach(subfield => { extract(subfield, storage); }); } /** * Interpolate values into a template string. * Simple mode replaces %VALUE% with primitive values. The mode is determined * by the absence of subfields in options. * Advanced mode replaces %key% placeholders with corresponding values from * the value object. Keys can be simple (e.g., %width%) or dotted for accessing * nested object values (e.g., %box.top%). * * Interpolate will return an empty string if: * - In simple mode, the value is not a primitive. * - In advanced mode, any referenced key does not exist in the value object. * - In advanced mode with subfields, any referenced key corresponds to a * subfield that is disabled by conditions. * - In advanced mode with subfields, any referenced key does not match * a defined subfield. * * @param {string} template - Template string with placeholders * @param {any} value - Primitive value or object with key-value pairs * @param {Object} options - Interpolation options * @param {string} [options.unit=''] - Unit to append to value (simple mode only) * @param {SchemaField[]} [options.subfields] - Schema for subfield definitions * @param {Object} [options.conditions] - Conditions map from filterConditionalFields * @param {string} [options.fieldName] - Parent field name for condition key lookup * @returns {string} Interpolated string, or empty string if required keys are missing */ function interpolate(template, value, { unit = '', subfields, conditions, fieldName } = {}) { // Not interested in null/undefined values if (value == null) { return ''; } if (!Array.isArray(subfields)) { // Simple mode for primitive values: replace %VALUE% placeholder if (typeof value !== 'object') { return template.replace(/%VALUE%/gi, String(value ?? '') + unit); } // Arrays are not supported as input values, so we ignore that check. // Object value mode without subfields: replace %key% placeholders // This handles values like {top, right, bottom, left} return template.replace(/%([^%]+)%/g, (_, key) => { if (key.toUpperCase() === 'VALUE') { return ''; } const keyValue = value[key]; return String(keyValue ?? '') + unit; }); } if (!subfields.length) { return ''; } if (typeof value !== 'object' || value === null) { return ''; } const subfieldsByName = new Map(subfields.map(field => [ field.name, field ])); const keyPattern = /%([^%]+)%/g; const referencedKeys = []; let match; while ((match = keyPattern.exec(template)) !== null) { referencedKeys.push(match[1]); } // If any referenced key is disabled by conditions, return empty for (const key of referencedKeys) { const subfieldName = key.includes('.') ? key.split('.')[0] : key; const conditionKey = fieldName ? `${fieldName}.${subfieldName}` : subfieldName; if (conditions?.[conditionKey] === false) { return ''; } } let replaceFailed = false; const result = template .replace(/%([^%]+)%/g, (_, key) => { // Handle dotted keys for nested object values (e.g., %box.top%) // The first part must match a subfield name. if (key.includes('.')) { const [ subfieldName, valueKey ] = key.split('.'); const subfield = subfieldsByName.get(subfieldName); if (!subfield) { replaceFailed = true; return ''; } const nestedValue = value[subfieldName]; if (typeof nestedValue !== 'object' || nestedValue == null || nestedValue[valueKey] == null ) { replaceFailed = true; return ''; } const subfieldUnit = subfield.unit || ''; return String(nestedValue[valueKey] ?? '') + subfieldUnit; } // Simple key - must match a subfield (e.g., %top%) const subfield = subfieldsByName.get(key); if (!subfield || value[key] == null) { replaceFailed = true; return ''; } const subfieldValue = value[key]; const subfieldUnit = subfield.unit || ''; if (subfield.valueTemplate) { return interpolate(subfield.valueTemplate, subfieldValue, { unit: subfieldUnit, conditions }); } return String(subfieldValue) + subfieldUnit; }) .trim(); return replaceFailed ? '' : result; } /** * Converts the styles map into a stringified CSS stylesheet. * * @param {Map<string, string|Map<string,string>>} styles * @param {boolean} [inline=false] - Whether to render styles as inline * @returns {string} Stringified CSS rules */ function stringifyRules(styles, inline = false) { const rules = []; styles.forEach((value, key) => { if (inline) { const normalized = normalizeRules(value.values()); if (normalized.length) { rules.push(normalized.join(';') + ';'); } return; } if (key.startsWith('@media')) { const nestedRules = stringifyRules(value); rules.push(key.concat('{', nestedRules, '}')); } else { rules.push(key.concat('{', normalizeRules(value.values()).join(';'), ';}')); } }); return rules.join(''); // Remove any trailing `;` from each rule function normalizeRules(rules) { return [ ...rules ].map(rule => { return (rule.endsWith(';') ? rule.slice(0, -1) : rule).trim(); }) .filter(rule => rule.length > 0); } }; /** * Prepare a field for rendering by normalizing it to a standard structure. * * @param {string|null} [fieldClass] The value of the class property from the field schema * @param {any} value * @param {RuntimeStorage} [storage] * @returns {NormalizedField} */ function applyFieldClass(fieldClass, value, storage) { if (!value || !fieldClass || !storage?.classes) { return false; } if (typeof fieldClass === 'string' && !!value) { storage.classes.add(fieldClass); return true; } if (fieldClass !== true) { return false; } if (Array.isArray(value)) { for (const v of value) { if (typeof v === 'string') { storage.classes.add(v); } } return value.length > 0; } else if (typeof value === 'string') { storage.classes.add(value); return true; } return false; }