Calling wp_enqueue_script() from inside a template survives review because it appears to work. By the time a template file runs, wp_head has already fired, so the handle is either printed in the footer by accident or dropped without a word — and the dependency array, which is the entire reason the function exists, is never consulted.
function catalogue_assets() {
wp_enqueue_style(
'catalogue',
get_stylesheet_directory_uri() . '/css/catalogue.css',
array(),
'1.2.0'
);
if ( is_singular( 'product' ) ) {
wp_enqueue_script(
'catalogue-gallery',
get_stylesheet_directory_uri() . '/js/gallery.js',
array( 'jquery' ), // load after the bundled jQuery, in noConflict mode
'1.2.0',
true // before </body>
);
}
}
add_action( 'wp_enqueue_scripts', 'catalogue_assets' );
wp_enqueue_scripts is the hook for the front end; admin_enqueue_scripts and login_enqueue_scripts are the other two, and using the front-end one for an admin screen is why a script sometimes loads on the site and not in the dashboard. Registering here means WordPress resolves the dependency graph and emits handles in a valid order, so nothing has to know what anything else needs, and the version argument becomes a real cache-buster — it should change with the file rather than sit at the theme version forever. The conditional is the part that pays: the hook fires on every request, so is_singular() inside it sends the gallery only where there is a gallery, which a <script> tag in header.php can never do. One thing not to do while you are in there: wp_deregister_script( 'jquery' ) in favour of a CDN copy breaks every plugin expecting the bundled 1.7.2 in noConflict mode, and the breakage does not appear on the pages you tested.