UNPKG

apostrophe

Version:
376 lines (358 loc) • 12.7 kB
const _ = require('lodash'); const presets = require('./presets'); const { klona } = require('klona'); const breakpointPreviewTransformer = require('postcss-viewport-to-container-toggle/standalone'); module.exports = (self, options) => { return { // Public APIs // Extend this method to register custom presets, // Do not call this method directly. // Example (improve @apostrophecms/styles from npm package or project level): // extendMethods(self) { // return { // registerPresets(_super) { // _super(); // self.setPreset('customPreset', { ... }) // // OR extend an existing one: // const borderPreset = self.getPreset('border'); // borderPreset.fields.add.def = true; // self.setPreset('border', borderPreset); // } // } // } registerPresets() { // noop by default }, // Preset management. Can be called only // inside of extended registerPresets() method. setPreset(name, preset) { if (self.schema) { throw new Error( `Attempt to set preset ${name} after initialization.` + 'Presets must be set inside of extended registerPresets() ' + 'method of @apostrophecms/styles module.' ); } self.validatePreset(preset); self.presets[name] = preset; }, // Retrieve a preset by name. // Call only after presets have been registered. getPreset(name) { if (!self.presets) { throw new Error( 'Presets have not been initialzed yet. ' + 'Presets can be retrieved only after registration.' ); } return self.presets[name]; }, hasPreset(name) { return !!self.getPreset(name); }, expandStyles(stylesCascade) { const expanded = {}; for (const [ key, value ] of Object.entries(stylesCascade)) { // shorthand, example: // border: 'border' -> border: { preset: 'border' } if (typeof value === 'string') { if (!self.hasPreset(value)) { throw new Error(`Unknown preset "${value}" used in styles schema for field "${key}"`); } expanded[key] = klona(self.getPreset(value)); continue; } if (value?.preset) { if (!self.hasPreset(value.preset)) { throw new Error(`Unknown preset "${value.preset}" used in styles schema for field "${key}"`); } expanded[key] = Object.assign( klona(self.getPreset(value.preset)), value ); delete expanded[key].preset; continue; } expanded[key] = value; } return expanded; }, // Returns the locale-qualified stylesheet URL for the given // request. The path encodes the locale so that static-build // tools produce a distinct file per locale. // // Options: // `relative` - if true, return a prefix-free path suitable // for route-relative contexts (e.g. `getAllUrlMetadata`). // Defaults to `false`, which prepends `apos.prefix` so the // URL works in rendered HTML (`<link>` tags, etc.). getStylesheetUrl(req, { relative = false } = {}) { const prefix = relative ? '' : (self.apos.prefix || ''); const version = req.data.global?.stylesStylesheetVersion; return `${prefix}${self.action}/stylesheet/locale/${req.locale}/${req.mode}${version ? `?version=${version}` : ''}`; }, // Shared implementation for the stylesheet API routes. // Sets cache headers and returns the raw CSS string. serveStylesheet(req) { req.res.setHeader('Content-Type', 'text/css'); req.res.setHeader('Cache-Control', 'public, max-age=31557600'); return req.data.global?.stylesStylesheet || ''; }, stylesheet(req) { // Stylesheet node should be created only for logged in users. if (!req.data.global) { return []; } const nodes = []; // Only guests can't view drafts. This test is commonly used to // distinguish potential editors who might use breakpoint preview // and similar features from those who should just get the link tag const hasLink = !self.apos.permission.can(req, 'view-draft'); if (req.data.global.stylesStylesheet && hasLink) { const href = self.getStylesheetUrl(req); nodes.push({ name: 'link', attrs: { rel: 'stylesheet', href } }); } if (!hasLink) { nodes.push({ name: 'style', attrs: { id: 'apos-styles-stylesheet' }, body: [ { raw: req.data.global.stylesStylesheet || '' } ] }); } return nodes; }, ui(req) { return [ { name: 'div', attrs: { id: 'apos-styles' } } ]; }, // Returns object with `css` (string) and `classes` (array) properties. // `css` contains full stylesheet that should be wrapped in // <style> tag. // `classes` contains array of class names that should be applied // to the <body> element (`class` attribute). getStylesheet(doc) { return self.stylesheetGlobalRender(self.schema, doc, { checkIfConditionsFn: self.styleCheckIfConditions }); }, // Returns object with `css` (string), `inline` (string) and `classes` (array) // properties. // `css` contains full stylesheet that should be wrapped in // <style> tag. // `inline` contains inline styles that should be applied to the // widget's wrapper element (`style` attribute). // `classes` contains array of class names that should be applied // to the widget's wrapper element (`class` attribute). // Options: // - rootSelector: string - custom root selector for scoped styles getWidgetStylesheet(schema, doc, options = {}) { return self.stylesheetScopedRender(schema, doc, { ...options, checkIfConditionsFn: self.styleCheckIfConditions }); }, // Generate unique ID, Invoke the widget owned `getStylesheet` method // and return object: // { // css: '...', // full stylesheet to go in <style> tag // classes: [ ... ], // array of classes to go in wrapper `class` attribute // inline: '...' // inline styles to go in wrapper `style` attribute // styleId: '...', // ID for the style element and wrapper `id` attribute // widgetId: '...' // the widget's _id // } // It's used directly by @apostrophecms/widget-type module and it's proxied // as a styles helper for use in widget templates that opt out of // automatic stylesWrapper. prepareWidgetStyles(widget) { const widgetManager = self.apos.area.getWidgetManager(widget.type); if (!widgetManager) { return { css: '', classes: [], inline: '', styleId: '', widgetId: '' }; } const styleId = self.apos.util.generateId(); const styles = widgetManager.getStylesheet( widget, styleId ); styles.styleId = styleId; styles.widgetId = widget._id; return styles; }, // Renders style element for use inside widget templates. // Expects the result of prepareWidgetStyles() method as input. // Proxied as a style helper for use in widget templates that // opt out of automatic stylesWrapper. // Options: // - scene: string - scene name, required for the mobile preview support getWidgetElements(styles, { scene } = {}) { let { css, styleId, widgetId } = styles || {}; if (!css || !styleId) { return ''; } const assetOptions = self.apos.asset.options.breakpointPreviewMode || {}; if (css && assetOptions.enable && scene === 'apos') { css = breakpointPreviewTransformer(css, { modifierAttr: 'data-breakpoint-preview-mode', debug: assetOptions.debug === true, transform: assetOptions.transform || null }); } return `<style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n` + css + '\n</style>'; }, // Renders attributes string for use inside widget templates. // Expects the result of prepareWidgetStyles() method as input. // Proxied as a style helper for use in widget templates that // opt out of automatic stylesWrapper. // Optional second argument `additionalAttrs` is an object with // additional attributes to merge. `class` and `style` attributes // are merged with the styles values, keeping classes unique. // Optional third argument `options`: // - asObject: boolean - if true, returns attributes as an object getWidgetAttributes(styles, additionalAttrs = {}, options = {}) { const { classes = [], inline, styleId, widgetId } = styles || {}; if (!styleId) { return ''; } // Separate class and style from other additional attributes const { class: additionalClasses, style: additionalStyle, ...otherAttrs } = additionalAttrs; const attrs = [ [ 'id', styleId ] ]; attrs.push( [ 'data-apos-widget-style-wrapper-for', widgetId || '' ] ); attrs.push( [ 'data-apos-widget-style-classes', classes.join(' ') ] ); // Merge classes, keeping them unique const classSet = new Set(classes); if (additionalClasses) { const extraClasses = Array.isArray(additionalClasses) ? additionalClasses : additionalClasses.split(/\s+/).filter(Boolean); extraClasses.forEach(cls => classSet.add(cls)); } if (classSet.size) { attrs.push( [ 'class', Array.from(classSet).join(' ') ] ); } // Merge inline styles const styleParts = []; if (inline) { // Remove trailing semicolon to avoid double semicolons when joining styleParts.push(inline.replace(/;$/, '')); } if (additionalStyle) { // Remove trailing semicolon from additional style as well styleParts.push(additionalStyle.replace(/;$/, '')); } if (styleParts.length) { attrs.push( [ 'style', `${styleParts.join(';')};` ] ); } // Add other additional attributes for (const [ key, value ] of Object.entries(otherAttrs)) { if (value !== undefined && value !== null) { attrs.push( [ key, value ] ); } } if (options.asObject) { return Object.fromEntries(attrs); } return attrs .map(([ key, value ]) => `${key}="${value}"`) .join(' '); }, // Internal APIs // Do not call this method directly. // Called only once inside of composeSchema() method. // Sets up standard presets. setStandardPresets() { for (const [ name, preset ] of Object.entries(presets(options))) { self.setPreset(name, preset); } }, // FIXME: currently deprecated, a subject to removal. // See extendMethods.js:composeSchema() ensureNoFields() { if ( Object.keys(self.fields || {}).length && Object.keys(self.styles || {}).length ) { throw new Error( 'The @apostrophecms/styles module does not support standard schema ' + 'fields and style fields at the same time. ' + 'Remove the "fields" property from the module configuration ' + 'and use the "styles" configuration only.' ); } }, // basic duck typing to help the developer do the right thing validatePreset(preset) { if (!preset?.type) { throw new Error('Preset must be an object with a "type" property.'); } }, addToAdminBar() { if (Object.keys(self.styles).length === 0) { return; } self.apos.adminBar.add( '@apostrophecms/styles', 'apostrophe:stylesToggle', { action: 'edit', type: '@apostrophecms/styles' }, { icon: 'palette-icon', contextUtility: true, tooltip: 'apostrophe:stylesOpen' // To put back when we support confirmation modal /* toggle: true */ /* tooltip: { */ /* activate: 'apostrophe:stylesOpen' */ /* deactivate: 'apostrophe:stylesClose' */ /* } */ } ); } }; };