WP Manifestindependent plugin directory
manifest / developer / _fuse-cms-framework

Fuse CMS Framework for WordPress

The Fuse CMS Framework for WordPress

by 7-90 Systems · github.com/7-90systems/_fuse-cms-framework · website

0stars
0forks

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.zip

A 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.

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 heremod_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_header is not inherited by a location that 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 at edit_posts — contributor and up. They are now editor and 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) — accepts image, size, alt, class and caption

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) — every save_post handler must call this before writing anything. It rules out autosaves and revisions, checks edit_post on that specific post, and verifies WordPress's own post edit nonce.
  • fuse_sanitise_meta ($value) — walks a string or nested array from $_POST and runs every scalar through sanitize_text_field ().
  • fuse_sanitise_html ($value)wp_kses_post () for values meant to hold markup.
  • fuse_block_direct_access () — dies if ABSPATH is 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_all filter as theme styles. 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 on add_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, plus fuse_before_enqueue_css / fuse_after_enqueue_css and 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.