Fuse CMS Framework for WordPress
The Fuse CMS Framework for WordPress
by 7-90 Systems · github.com/7-90systems/_fuse-cms-framework · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/7-90systems/_fuse-cms-framework/archive/refs/heads/master.zipA framework plugin that gives a WordPress site a shared foundation: a class autoloader, a page layout system, a form and settings builder, an optional set of post types and editor blocks, and an update channel for Fuse plugins and themes.
This is not a standalone feature plugin. It is the base a Fuse theme and any Fuse companion plugin are built on, and most of what it provides is only reachable from theme or plugin code.
- Author: 7-90 Systems — https://7-90.com.au
- Plugin URI: https://fusecms.org
- Version: 2.0
- Requires: WordPress 6.0+, PHP 7.4+
- Text domain:
fuse
Installation
Activate the plugin. On activation it creates a fuse_layouts post called
"Global Default Layout" and sets it as the site-wide default, but only if no layout
already exists.
Most features are off until switched on under Fuse CMS → Site Settings.
Settings
A top-level Fuse CMS menu is added, gated on manage_options. Its settings are
stored as options prefixed fuse_setting_, read and written with
get_fuse_option () and update_fuse_option ().
| Panel | Setting | Effect |
|---|---|---|
| Email Sender | fuse_email_from_name, fuse_email_from_email |
Sets the site's outgoing mail sender |
| Theme CSS Styles | theme_css_layout |
Enables the bundled layout stylesheet |
theme_css_buttons |
Enables the bundled button stylesheet | |
theme_css_block |
Disables the Gutenberg block stylesheets | |
theme_css_woo |
Disables the WooCommerce stylesheets (only shown when WooCommerce is active) | |
| Theme Features | faq_posttype |
Enables the FAQ post type and its block |
sliders_posttype |
Enables the Slider and Slide post types and the slider block | |
tabs_block |
Enables the tabs editor block | |
html_fragments |
Enables the AJAX HTML fragments system | |
web_fonts |
Auto-loads web fonts found in the theme | |
fallback_image |
Attachment used when an image is missing | |
| Development Features | pagetype_builder |
Enables the Post Type Builder |
| Header & Footer Scripts | header_scripts, body_scripts, footer_scripts |
Markup printed verbatim in wp_head, wp_body_open and wp_footer |
| Google API | google_api_key |
Key used for Maps and geocoding |
| Contact Details | contact_<location>_<field> |
Phone, email, street, town, state and postcode per location |
| Security | security_* |
See below |
The three script fields are printed unescaped by design, which is why the settings
screen and its save handler both require manage_options.
Slashes
Form::save () unslashes what it is given before storing it. WordPress slashes the
whole of $_POST on the way in; the metadata API takes that back out again, which is
why the meta box path never needed it, but the options API does not — update_option ()
stores exactly what it is handed.
Without the unslashing, every quote in a setting gained a backslash, and gained another
one on each save after that. It went unnoticed because almost every Fuse setting is a
toggle, a number or a short piece of plain text; the Content-Security-Policy value is
the first one with quotes in it, and Rules::safeValue () turns each stray backslash
into a space — so an enabled CSP was written to .htaccess as
default-src 'self '; script-src 'self ', which is not a valid policy.
Settings stored before the fix are repaired once by Install::repairSlashedOptions (),
run on admin_init and flagged with the fuse_settings_unslashed option. It only
unwinds a value where every backslash is one addslashes () could have put there —
in front of a quote, another backslash or a NUL. A backslash in front of anything else,
such as a Windows path or a regular expression, was typed deliberately, and the value is
left alone rather than quietly corrupted.
Contact locations default to a single default location. Add more with the
fuse_settings_contact_locations filter, and change the per-location fields with
fuse_settings_contact_fields.
Security
A security baseline, on its own Security tab of the settings form. The panel is built from Fuse form fields, so the settings form renders and saves it like any other panel.
Everything defaults to off. The framework updates itself across live sites, and a release that quietly started blocking requests would be discovered by a broken site rather than by being read about here.
Applied in PHP — works on any server
| Setting | Does |
|---|---|
security_xmlrpc |
Closes XML-RPC and stops it being advertised |
security_rest_users |
Hides the REST user list from anyone not logged in |
security_author_enum |
Turns ?author=1 into a 404 and drops users from the sitemap |
security_version |
Removes the WordPress version from the head and the feeds |
security_version_assets |
Also strips it from asset URLs — only WordPress's own |
security_file_edit |
Defines DISALLOW_FILE_EDIT, removing the plugin and theme file editors |
Written to .htaccess — Apache and LiteSpeed only
Behind security_htaccess, off by default. Covers the security headers
(X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, HSTS, and
CSP in report or enforce mode), blocking the files that give a site away —
readme.html, .env, debug.log, any copy of wp-config — refusing to run PHP under
the uploads folder (security_uploads_php), and turning off directory listings.
The Permissions-Policy refuses both cohort names. interest-cohort=() was the FLoC
opt-out; Chrome withdrew FLoC and replaced it with the Topics API, whose name is
browsing-topics. The default refuses both — the new name because it is the one that
does anything now, the old one because a browser still on the earlier build reads only
that, and an entry a browser does not recognise is ignored rather than costing anything.
Changing a default only reaches a site that has never saved the setting, and any site
with the security tab switched on has saved it. So Install::addBrowsingTopics () tops
up an existing policy once, on admin_init, flagged with the
fuse_settings_browsing_topics option. It is deliberately narrow: it only touches a
policy that still refuses interest-cohort and says nothing about browsing-topics,
which is the shape this framework wrote and nothing else. A policy that has been
rewritten for the site is left alone, and the change is only ever an addition.
The field itself is Browser features, and its note covers allowing as well as
refusing — () refuses a feature to everybody, (self) allows it on the site's own
pages, (self "https://example.com") extends that to an embedded origin, and * allows
it anywhere. Leaving a feature out of the policy does not close it: most default to
self, so refusing has to be written down.
The uploads rule is a rewrite, not a FilesMatch. A FilesMatch in a root
.htaccess cannot be scoped to one folder — it would match those file names everywhere
and take the whole site down with it. The rewrite tests the request path, so it stops at
uploads. It covers every extension a server is commonly configured to hand to PHP
(.php, .php5, .phtml, .phar, .phps), because a handler for one of those is
ordinary and an upload named to suit it would otherwise walk straight past.
The uploads path is read from wp_upload_dir () rather than assumed — the UPLOADS
constant, an upload_path option and some hosts all move it. A folder outside the
WordPress directory cannot be reached from the root .htaccess at all, and the field
says so instead of writing a rule that does nothing.
security_file_edit is applied in PHP, not here. It defines DISALLOW_FILE_EDIT on
after_setup_theme, which is early enough: WordPress reads the constant when it maps the
edit_plugins and edit_themes capabilities and when the editor screens load, both
later. A definition already in place wins — wp-config.php is the documented home for it
— and the field description says which is in force.
The block is rewritten whenever the settings are saved, and taken out again when the
switch goes off or the plugin is deactivated. Setup\Security\Environment works out what
the server can actually do and says so in the field descriptions, so an nginx site is
told the rules will not apply rather than being left to wonder.
Which server does what
The protections split cleanly: the first group is PHP and runs anywhere, the second is server directives and depends on what the server reads.
| Setting | Apache | LiteSpeed | nginx | IIS |
|---|---|---|---|---|
security_xmlrpc |
yes | yes | yes | yes |
security_rest_users |
yes | yes | yes | yes |
security_author_enum |
yes | yes | yes | yes |
security_version |
yes | yes | yes | yes |
security_version_assets |
yes | yes | yes | yes |
security_file_edit |
yes | yes | yes | yes |
security_headers |
.htaccess | .htaccess | block to paste | by hand |
security_files |
.htaccess | .htaccess | block to paste | by hand |
security_uploads_php |
.htaccess | .htaccess | block to paste | by hand |
security_indexes |
.htaccess | .htaccess | block to paste | by hand |
LiteSpeed reads .htaccess and implements the Apache directives used here —
mod_headers, mod_rewrite, mod_alias and FilesMatch — so it is written to exactly
as Apache is. Environment checks for it before Apache, since LiteSpeed identifies as
both. Every directive is wrapped in <IfModule>, so a server missing a module ignores
that block rather than throwing a 500.
nginx never reads .htaccess, so the same rules are generated as nginx syntax and
shown in the panel under the Write server rules to .htaccess field, ready to paste
into the server { } block. The switches above it still record what is wanted, so the
block changes as they do. Two things travel with it as comments, because whoever pastes
it is usually not whoever read the settings screen:
- It has to go above any
location ~ \.php$block. nginx uses the first regex location that matches, so a PHP handler placed first would run the very files the uploads rule exists to stop. add_headeris not inherited by alocationthat sets its own, so any such block needs these repeated inside it.
The nginx block includes HSTS whether or not the site is currently on HTTPS, since it is
written to be applied later; the .htaccess version holds it back until the site is
actually secure. The field description warns when the site is not on HTTPS either way.
IIS gets neither. Only Apache and nginx syntax is generated, so the panel says the rules have to be translated by hand rather than promising a block that does not exist.
Setup\Security\Rules builds all of it, and Setup\Security\Environment works out
which case applies.
Layouts
Layouts are a fuse_layouts post type. Each layout decides which of six regions are
shown — header, two left columns, two right columns and footer — which sidebar goes
in each column, and any extra body classes. A layout can be set as the default for
the site, for a post type, for a post type archive, for a taxonomy, or for the 404
page; individual posts can override it from a Layout meta box.
The resulting body classes (fuse-layout-three-col, fuse-layout-single-left-col,
with-header and so on) are added through the body_class filter.
Post types
| Slug | Purpose | Enabled by |
|---|---|---|
fuse_layouts |
Page layouts | Always |
fuse_faq |
FAQs, with a fuse_faq_section taxonomy |
faq_posttype |
fuse_slider |
Sliders | sliders_posttype |
fuse_slide |
Slides within a slider, with optional start and end dates | sliders_posttype |
fuse_posttype |
Post Type Builder — defines further post types and their meta boxes from the admin | pagetype_builder |
Permissions
Fuse\PostType takes two arguments of its own alongside everything
register_post_type () accepts:
| Argument | Decides | Default |
|---|---|---|
view |
Who may see items that are not public — private and draft | editor |
edit |
Who may add, edit, publish and delete items | editor |
Both are role names, not capabilities:
new My_Post_Type ('report', 'Report', '', array (
'view' => 'editor',
'edit' => 'administrator'
));
WordPress gates a post type on capabilities rather than roles, so each role name becomes the capability that stands for it — one held by that role and every role above it, and by none below, so naming a role reads as "this role and up":
| Role | Capability |
|---|---|
administrator |
manage_options |
editor |
edit_others_posts |
author |
publish_posts |
contributor |
edit_posts |
subscriber |
read |
A site that has moved those capabilities between roles can move the mapping with them
through the fuse_posttype_role_capabilities filter. A name that is not in the table
falls back to editor rather than being used literally — a typo turned into a
capability nobody holds would lock everybody out, administrators included, and the post
type would look broken rather than misconfigured.
view and edit are removed from the arguments before registration, so they never
reach register_post_type (), which would ignore them silently. map_meta_cap is
turned on with them, since without it WordPress never maps edit_post, delete_post
and read_post onto the capabilities set here. A post type that supplies its own
capabilities array keeps it — anything it sets wins.
read is left alone on a public post type. It is what map_meta_cap () checks for
read_post on an item that is already published, so tying it to a role would take a
public post type away from visitors, who hold no capabilities at all. On a post type
that is not public there is no visitor to lock out and the view role decides.
This changed the default. Post types previously registered with
capability_type => 'post'and nothing else, which put them atedit_posts— contributor and up. They are noweditorand up unless a post type says otherwise. On a site where authors or contributors maintain a Fuse post type, set'edit' => 'contributor'(or'author') on it.
Blocks
| Block | Enabled by |
|---|---|
fuse/tabs and fuse/tab |
tabs_block |
fuse-slider/main |
sliders_posttype |
dynamic-select-block/main — the FAQs block |
faq_posttype |
The FAQs block name is a leftover from the block it was originally derived from. Renaming it would invalidate existing content, so it has been left as it is.
Shortcodes
[content_block][content_column][contact_field]
Each renders a template, resolved from the theme first and then from the plugin's
templates/shortcodes/ directory. Add more with fuse_register_shortcodes.
Template functions
Available to themes once the plugin is active. Loaded from functions/.
Options and meta
get_fuse_option ($name, $default)/update_fuse_option ($name, $value)get_fuse_post_meta ($post_id, $name, $single)/update_fuse_post_meta ($post_id, $name, $value, $prev)
Contact details
fuse_get_contact_field ($field, $location)fuse_get_contact_phone ($field, $location, $link, $link_text)fuse_get_contact_email ($field, $location, $link, $link_text)
Template parts and navigation
fuse_get_header ($location),fuse_get_footer ($location),fuse_get_sidebar ($location)fuse_paging_nav ($args),fuse_comments_paging_nav ($args)
Images
fuse_get_image ($image_id, $size, $fallback)/fuse_get_image_url (...)fuse_get_feature_image ($post, $size, $fallback)/fuse_get_feature_image_url (...)fuse_responsive_image ($args)— acceptsimage,size,alt,classandcaption
FAQs
fuse_faqs_list ($section_id)
Markup helpers
fuse_format_attribute ($value, $name, $render)fuse_format_attributes ($attributes, $hide_empty, $render)fuse_format_phone_number_link ($phone)
Security helpers
fuse_can_save_post_meta ($post_id)— everysave_posthandler must call this before writing anything. It rules out autosaves and revisions, checksedit_poston that specific post, and verifies WordPress's own post edit nonce.fuse_sanitise_meta ($value)— walks a string or nested array from$_POSTand runs every scalar throughsanitize_text_field ().fuse_sanitise_html ($value)—wp_kses_post ()for values meant to hold markup.fuse_block_direct_access ()— dies ifABSPATHis not defined.
For developers
Autoloading. Classes are resolved from the Fuse namespace:
| Namespace | Resolves to |
|---|---|
Fuse\Foo\Bar |
library/Foo/Bar.php in this plugin |
Fuse\Theme\<Name>\Foo |
library/Foo.php under FUSE_THEME_<NAME>_BASE_URI |
Fuse\Plugin\<Name>\Foo |
library/Foo.php under FUSE_PLUGIN_<NAME>_BASE_URI |
A theme or companion plugin defines its own FUSE_THEME_*_BASE_URI or
FUSE_PLUGIN_*_BASE_URI constant to join the autoloader. If the constant is not
defined the autoloader simply passes, so an unrelated class of the same shape will
not break the request.
Function files. Every .php file in functions/ is loaded automatically, except
index.php. Add your own directories with the fuse_load_functions_from filter.
Getting in early. fuse_init is the hook a companion plugin should use. It fires
on plugins_loaded, so every plugin has been loaded and had the chance to register a
listener before it runs. fuse_register_posttypes fires later, when post types are
being registered.
A theme cannot use fuse_init -- theme files load after plugins_loaded. A theme
should call its own setup directly from functions.php, after defining its
FUSE_THEME_*_BASE_URI constant.
Stylesheets and scripts. The framework walks the theme's asset directories and
registers what it finds, so a stylesheet or script is picked up by being put in the
right folder rather than by being enqueued by hand. Files are registered in sorted
order, and any file named default is always enqueued.
To give a file dependencies, put a .dep file beside it with the same name — so
slider.js is given its dependencies by slider.dep. The file lists the handles that
must load first, separated by pipes, by line breaks, or by both:
superfish|mmenulight
superfish
mmenulight
superfish|mmenulight
colorbox
All three are read the same way. Handles are trimmed, and blanks and duplicates are dropped, so a trailing newline or a stray space is harmless. Order is preserved, because it is the load order.
Styles in the editor. The same stylesheets the front end loads are given to the
editor too, so what is being written looks like what will be published. That covers the
framework's optional layout and button stylesheets, whatever is found in the theme's css
folders, and the theme's style.css.
They are scoped to the content area, which is the part that matters if you have ever
had a .button rule repaint the Publish button:
- In the block editor the stylesheet contents go in through the
block_editor_settings_allfilter asthemestyles. The editor rewrites every selector in those to sit under.editor-styles-wrapper, so a rule can only ever reach something inside the content area. This depends onadd_theme_support ('editor-styles'), which the framework declares. - In the classic editor they are added to
mce_css, and TinyMCE loads them inside its own iframe, which is a separate document.
Enqueueing the same CSS on enqueue_block_editor_assets instead puts it on the editor
page unscoped, and that is what makes styles bleed over the admin chrome. Use that hook
only for styling the editor interface itself.
The per-page stylesheets follow the same rules the front end uses, so the editor picks up the ones that apply to the post being written:
| Stylesheet | In the editor |
|---|---|
default*, header, footer |
yes — they always apply |
posttype_<type> |
yes, for the type being edited |
<type>_<slug>, <type>_<id> |
yes, for that post |
page_home |
yes, when editing the front page |
blocks_*, shortcode_* |
yes, all of them — any can be inserted while editing |
posttypearchive_*, taxonomy_*, tag_*, 404 |
no |
The last row is deliberate: the editor is showing a single post, so an archive or
taxonomy stylesheet could never be the right one for it. Adjust the list with the
fuse_editor_stylesheets filter, which receives the stylesheets and the post.
Note that a full URL passed to add_editor_style () makes WordPress fetch it back over
HTTP on every editor load, so the framework hands it theme-relative paths and reads the
files from disk instead.
Switching fields off. Any field can be rendered disabled, and switched on or off again from JavaScript afterwards:
new Component\Field\Toggle ('a_setting', __ ('A setting'), $value, array (
'disabled' => true
));
$field->setDisabled (true);
$field->isDisabled ();
Every field carries data-fuse-field with its own name, so a script can find one without
knowing how its id was built:
fuseForms.disableField ('security_header_hsts');
fuseForms.enableField ('security_header_hsts');
fuseForms.setFieldDisabled ('security_header_hsts', on === false);
fuseForms.isFieldDisabled ('security_header_hsts');
fuseForms.fieldValue ('security_header_hsts');
A disabled field keeps its setting. A disabled control is not submitted, and the settings form saves an empty value for anything missing from the request — so a field switched off would otherwise wipe what was behind it. Disabling leaves a hidden copy of the value instead, both in PHP and when done from the script. A toggle needs no copy: only the list the user clicks is switched off, and its hidden input keeps posting.
Covers Text (and Email, Number, Url), TextArea, Select, Checkbox and Toggle. The media fields — File, Gallery, Image — and IconGroup, Date and DateTime are not wired up yet.
Hooks. The main extension points, all documented in the docblock of the class that fires them:
- Setup —
fuse_init,fuse_before_load_functions,fuse_after_load_functions,fuse_load_functions_from,fuse_register_posttypes - Theme —
fuse_theme_supports,fuse_nav_menus,fuse_sidebars,fuse_image_sizes,fuse_register_shortcodes,fuse_register_assets - Assets —
fuse_css_dependencies,fuse_javascript_dependencies, and the matching*_admin_*and*_login_*variants, plusfuse_before_enqueue_css/fuse_after_enqueue_cssand their JavaScript pair - Layout —
before_fuse_post_layout,after_fuse_post_layout,fuse_layout_sidebar_class,fuse_sidebar_classes - Settings —
fuse_settings_form_panels,fuse_settings_contact_fields,fuse_settings_contact_locations,fuse_admin_menu - Fragments —
fuse_theme_fragments
HTML fragments. With html_fragments on, the plugin prints a small script that
calls admin-ajax.php after load and replaces the inner HTML of elements by ID. Hook
fuse_theme_fragments and return an array keyed by element ID. It is intended for
keeping a few dynamic areas live on an aggressively cached site.
Updates
Plugins and themes carrying a Fuse Update Server: header are offered updates from
that server. This plugin performs the check for all of them.
The scheme is forced to HTTPS. The update endpoint decides which package the site downloads, so a plain HTTP exchange there is remote code execution for anyone able to intercept it. A server without HTTPS will fail its update check rather than fall back. Loopback addresses are the only exception, so a local update server can still be used in development.
Bundled libraries
assets/external/ carries bxSlider, Colorbox, mmenu-light, slick, Superfish and the
jQuery UI stylesheet. The jQuery UI script is deliberately not bundled — every
jQuery UI script comes from WordPress core's own jquery-ui-* handles. Only the
stylesheet is kept, because core registers no full jQuery UI theme stylesheet.
Licence
The framework is GPL-3 -- see LICENSE. The libraries bundled under
assets/external/ carry their own licences; check each one there.