apostrophe
Version:
The Apostrophe Content Management System.
376 lines (358 loc) • 12.7 kB
JavaScript
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' */
/* } */
}
);
}
};
};