Intro
In WordPress, there are many ways to output a stylesheet <link>
or JS <script>
tag, but it might be unclear how to add arbitrary HTML attributes.
Through this guide, let’s pretend that our goal is to add the arbitrary attribute data-theme
, and a value of light
:
- <link rel="stylesheet" href="styles.css" />
+ <link rel="stylesheet" href="styles.css" data-theme="light" />
The easiest, but most unsafe way, is with the wp_head or wp_footer hooks. For example, if you wanted to simply output the tag in the <head>
of the document, you could use something like this:
function add_my_tags() {
?>
<!-- We can output whatever we want here, including arbitrary attributes -->
<link rel="stylesheet" href="styles.css" data-theme="light" />
<?php
}
add_action('wp_head', 'add_my_tags');
However, this is not ideal, and goes against best practices; since you are not enqueuing the resources normally, there is no way for other plugins or theme files to know about it, and you might end up with duplicate scripts and styles getting loaded.
This can be especially problematic for JavaScript files, as they might be required to be loaded in a special order, or will error if loaded more than once.
Idea: Reusable and Shared Data
The safest way to add resource and scripts in WP is always through the global enqueue methods; wp_enqueue_style() and wp_enqueue_script(). However, these methods do not offer any way to add custom attributes to be echoed in the HTML.
What about the add_data()
method, hmm? That looks pretty tempting…
Ah, no, that is very misleading. While add_data()
does allow you add arbitrary properties to a enqueued object internally in WordPress (technically an instance of _WP_Dependency
), only certain attributes are actually processed by WP and affect the echoed out HTML. For example, for style tags (<link>
), WP will recognize:
rtl
(link)suffix
is combined with this (link)
conditional
(link) (used to inject legacy conditional comments)alt
(link)title
(link)
This is also called out / mentioned in the actual exposed functions documentation:
However, this gives me an idea… what if we could use the power of add_data to store the attributes across the WP system, but then hook into the HTML generation filters to alter the output? We can; read-on for my solution.
My Solution
Although arbitrary properties added through add_data
that WP does not know about do not affect output by default, that doesn’t mean we can’t use the underlying system to come up with our own solution, and make it work that way!
Here is what I came up with, which is flexible and reusable. All the code on this page can also be found in this Github Gist.
<?php
/**
* @author Joshua Tzucker
* @license MIT
* @see https://joshuatz.com/posts/2020/adding-extra-attributes-to-style-and-script-tags-in-wordpress/
*/
/**
* These should stay global, so you can access them wherever
* you need to hack on an extra attribute
*
* @example
* ```php
* global $ARB_ATTRIB_PREFIX;
* wp_script_add_data($yourHandle, $ARB_ATTRIB_PREFIX . 'your_key', 'your_val');
* ```
*
* You can customize this prefix, but be careful about avoiding collisions, and escape any characters that will break building the regex pattern
*/
$ARB_ATTRIB_PREFIX = 'arb_att_&#';
$ARB_ATTRIB_PATTERN = '/' . $ARB_ATTRIB_PREFIX . '(.+)/';
/**
* Callback for WP to hit before echoing out an enqueued resource. This callback specifically checks for any key-value pairs that have been added through `add_data()` and are prefixed with a special value to indicate they should be injected into the final HTML
* @param {string} $tag - Will be the full string of the tag (`<link>` or `<script>`)
* @param {string} $handle - The handle that was specified for the resource when enqueuing it
* @param {string} $src - the URI of the resource
* @param {string|null} $media - if resources is style, should be the target media, else null
* @param {boolean} $isStyle - If the resource is a stylesheet
*/
function scriptAndStyleTagAttributeAdder($tag, $handle, $src, $media, $isStyle){
global $ARB_ATTRIB_PATTERN;
$extraAttrs = array();
$nodeName = '';
// Get the WP_Dependency instance for this handle, and grab any extra fields
if ($isStyle) {
$nodeName = 'link';
$extraAttrs = wp_styles()->registered[$handle]->extra;
} else {
$nodeName = 'script';
$extraAttrs = wp_scripts()->registered[$handle]->extra;
}
// Check stored properties on WP resource instance against our pattern
$attribsToAdd = array();
foreach ($extraAttrs as $fullAttrKey => $attrVal) {
$matches = array();
preg_match($ARB_ATTRIB_PATTERN, $fullAttrKey, $matches);
if (count($matches) > 1) {
$attrKey = $matches[1];
$attribsToAdd[$attrKey] = $attrVal;
}
}
// Actually do the work of adding attributes to $tag
if (count($attribsToAdd)) {
$dom = new DOMDocument();
@$dom->loadHTML($tag);
/** @var {DOMElement[]} */
$resourceTags = $dom->getElementsByTagName($nodeName);
foreach ($resourceTags as $resourceTagNode) {
foreach ($attribsToAdd as $attrKey => $attrVal) {
$resourceTagNode->setAttribute($attrKey, $attrVal);
}
}
$headStr = $dom->saveHTML($dom->getElementsByTagName('head')[0]);
// Capture content between <head></head>. Kind of hackish, but should be faster than preg_match
$content = substr($headStr, 7, (strlen($headStr) - 15));
return $content;
}
return $tag;
}
add_filter('script_loader_tag',function($tag, $handle, $src){
return scriptAndStyleTagAttributeAdder($tag, $handle, $src, null, false);
},10,4);
add_filter('style_loader_tag',function($tag, $handle, $src, $media){
return scriptAndStyleTagAttributeAdder($tag, $handle, $src, $media, true);
},10,4);
And now you can easily add any arbitrary attributes, like so!:
<?php
// This is all it takes now to add an attribute pair that will get picked up by our filter and injected into the HTML tag
global $ARB_ATTRIB_PREFIX;
// Script
wp_script_add_data($handle, $ARB_ATTRIB_PREFIX . 'your_attrib_key', 'your_attrib_val');
// Stylesheet (identical syntax)
wp_style_add_data($handle, $ARB_ATTRIB_PREFIX . 'your_attrib_key', 'your_attrib_val');
Going back to our original example:
<?php
global $ARB_ATTRIB_PREFIX;
wp_enqueue_style('my-theme', get_stylesheet_uri());
wp_style_add_data('my-theme', $ARB_ATTRIB_PREFIX . 'data-theme', 'light');
And here is a more practical example; we can use this system to enforce Subresource Integrity (SRI) on a <script>
tag, to make sure the contents does not change from the time we add it:
<?php
function addJqueryWithSRI(){
global $ARB_ATTRIB_PREFIX;
wp_enqueue_script('jquery-3', 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js');
wp_script_add_data('jquery-3', $ARB_ATTRIB_PREFIX . 'integrity', 'sha512-bLT0Qm9VnAYZDflyKcBaQ2gg0hSYNQrJ8RilYldYQ1FxQYoCLtUjuuRuZo+fjqhx/qtq/1itJ0C2ejDxltZVFg==');
wp_script_add_data('jquery-3', $ARB_ATTRIB_PREFIX . 'crossorigin', 'anonymous');
}
add_action('wp_enqueue_scripts','addJqueryWithSRI');
For those curious, this is not the first time I have written about hooking into the loader_tag filters – I had to use some similar code when figuring out how to add attributes like
defer
andasync
to tags, and wrote up my approach here. The research and coding I did for that was very helpful for this situation, and you should checkout that post if you are looking for more info on how WP generates the final HTML for stylesheets and scripts.
Why A Special Prefix?
You might be wondering why I’m using a unique string ($ARB_ATTRIB_PREFIX
) to prefix any attribute key names we are adding, when later, I’m removing it via Regex. The answer is two-fold:
- Collision avoidance with WP Core
- I want to be very sure that my function only adds attributes that were explicitly requested to be added; for example, the key
conditional
is already in use by WordPress to allow for conditional comment inclusion (this is a legacy IE thing) - Although I could filter out attribute keys that I know already belong to WP, that is not wise, since I would have to manually update it as WP Core changes
- I want to be very sure that my function only adds attributes that were explicitly requested to be added; for example, the key
- Collision avoidance with other plugins / themes
- I’m sure I’m not the only developer to ever think of hooking into
add_data
to store arbitrary parameters and then use them elsewhere. By forcing a unique prefix, I reduce the likelihood of a given key colliding with one added by a plugin or theme.
- I’m sure I’m not the only developer to ever think of hooking into
The sharing of $ARB_ATTRIB_PREFIX
via global
is not required; you could manually just copy and paste the unique prefix whenever you use add_data
, but we all know how dangerous typos can be 😂
Room for Improvement and Alternatives
I’m sure there are a lot of ways that the above solution could be improved or altered; it was whipped up in an afternoon to answer a question posed to me. For example:
- You could create a simple wrapper function around
wp_style_add_data
andwp_script_add_data
, so you don’t have to always remember to grab and then use the global prefix- I’ve included sample code for this in my gist.
- You could bypass
add_data
and come up with your system for associating handles with metadata - Instead of using a unique prefix, you could use a global array (kind of like my
$specialLoadHnds
solution in this post) - There might be cleaner (and/or safer) ways to transform the
$tag
string and modify attributes; I went with PHP’s DOM Library (e.i. the DOMDocument class) based on this StackOverflow.- Although RegEx as a solution for DOM manipulation is almost never a good idea, sometimes it is OK for situations like this one, and might be faster
The Best Solution
The best solution for this would be to have adding arbitrary attributes to script and style tags supported natively in WordPress methods to begin with!. This is not an unreasonable thing to ask of a CMS, nor is it a niche concern. What is frustrating is that is this has been proposed (multiple times), and there are unclosed tickets to add it (#22249, #33948) from 8 YEARS ago (2012)!
There have been entire new CMS frameworks, such as Gatsby, that have sprung to life and grown over just a fraction of that time period. Heck, the initial release of React was in 2013. I understand that the WordPress codebase is large and needs to cover a lot of legacy uses (old themes, plugins, etc.), but there is a point where this really should have seen more progress than it has, and that was several years ago IMHO.
The best scenario is that, any day now, WordPress core is updated with this feature released, and this whole article becomes obsolete (except for legacy WP installs) 😄