diff --git a/CHANGELOG.md b/CHANGELOG.md index 1430302..d2bf546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added a new Breadcrumbs dynamic block (`ls-plugin/breadcrumbs`) that builds its trail entirely from core WordPress data (front page, blog, category/tag/taxonomy archives with nested term ancestors, single posts, pages, search, and 404), with no dependency on any third-party plugin. Includes an editor placeholder preview and front-end styling for the `nav.ls-crumbs` trail. - Added a navigation ref auto-resolver: when a `core/navigation` block has no `ref` attribute, it is resolved at render time to the `wp_navigation` post matching a documented slug convention (`header-navigation` by default, filterable via `ls_plugin_nav_ref_slug`), so themes never need a hardcoded, environment-specific post ID. The lookup allows multilingual plugins (WPML, Polylang) to filter it and resolve the translated nav post for the current language. - Added default term seeding for Portfolio taxonomies (Industries, Software, Project types, and Services) so a fresh plugin install reproduces the approved term set instead of requiring manual term creation. - Added a Style Switcher block with selectable theme style variations and configurable icon display behaviour. diff --git a/build/blocks/breadcrumbs/block.json b/build/blocks/breadcrumbs/block.json new file mode 100644 index 0000000..c52f745 --- /dev/null +++ b/build/blocks/breadcrumbs/block.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "ls-plugin/breadcrumbs", + "version": "1.0.0", + "title": "Breadcrumbs", + "category": "theme", + "description": "Displays a breadcrumb navigation trail for the current page, built entirely from core WordPress data.", + "keywords": [ + "breadcrumb", + "breadcrumbs", + "navigation" + ], + "textdomain": "ls-plugin", + "editorScript": "file:./index.js", + "style": "file:./style-index.css", + "supports": { + "html": false, + "align": [ + "wide", + "full" + ], + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + } + }, + "render": "file:./render.php" +} \ No newline at end of file diff --git a/build/blocks/breadcrumbs/index.asset.php b/build/blocks/breadcrumbs/index.asset.php new file mode 100644 index 0000000..705af23 --- /dev/null +++ b/build/blocks/breadcrumbs/index.asset.php @@ -0,0 +1 @@ + array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-i18n'), 'version' => '6f5af63b3f3bd6404efb'); diff --git a/build/blocks/breadcrumbs/index.js b/build/blocks/breadcrumbs/index.js new file mode 100644 index 0000000..16fb1e0 --- /dev/null +++ b/build/blocks/breadcrumbs/index.js @@ -0,0 +1 @@ +(()=>{"use strict";var r,n={157(){const r=window.wp.blocks,n=window.wp.i18n,e=window.wp.blockEditor,i=window.ReactJSXRuntime,l=JSON.parse('{"UU":"ls-plugin/breadcrumbs"}');(0,r.registerBlockType)(l.UU,{icon:"admin-links",edit:function(){const r=(0,e.useBlockProps)({className:"ls-crumbs-placeholder"});return(0,i.jsx)("nav",{...r,"aria-label":(0,n.__)("Breadcrumb","ls-plugin"),children:(0,i.jsx)("span",{children:(0,n.__)("Breadcrumbs (dynamic)","ls-plugin")})})},save:function(){return null}})}},e={};function i(r){var l=e[r];if(void 0!==l)return l.exports;var o=e[r]={exports:{}};return n[r](o,o.exports,i),o.exports}i.m=n,r=[],i.O=(n,e,l,o)=>{if(!e){var s=1/0;for(u=0;u=o)&&Object.keys(i.O).every(r=>i.O[r](e[t]))?e.splice(t--,1):(a=!1,o0&&r[u-1][2]>o;u--)r[u]=r[u-1];r[u]=[e,l,o]},i.o=(r,n)=>Object.prototype.hasOwnProperty.call(r,n),(()=>{var r={863:0,23:0};i.O.j=n=>0===r[n];var n=(n,e)=>{var l,o,[s,a,t]=e,c=0;if(s.some(n=>0!==r[n])){for(l in a)i.o(a,l)&&(i.m[l]=a[l]);if(t)var u=t(i)}for(n&&n(e);ci(157));l=i.O(l)})(); \ No newline at end of file diff --git a/build/blocks/breadcrumbs/render.php b/build/blocks/breadcrumbs/render.php new file mode 100644 index 0000000..4d05975 --- /dev/null +++ b/build/blocks/breadcrumbs/render.php @@ -0,0 +1,136 @@ + 'ls-crumbs', + 'aria-label' => __( 'Breadcrumb', 'ls-plugin' ), + ) +); + +$ls_breadcrumbs_before = ''; + +$ls_breadcrumbs_trail = array(); + +if ( is_front_page() ) { + $ls_breadcrumbs_trail[] = array( + 'label' => __( 'Home', 'ls-plugin' ), + 'url' => null, + ); +} else { + $ls_breadcrumbs_trail[] = array( + 'label' => __( 'Home', 'ls-plugin' ), + 'url' => home_url( '/' ), + ); + + if ( is_home() ) { + $ls_breadcrumbs_page_for_posts = (int) get_option( 'page_for_posts' ); + $ls_breadcrumbs_trail[] = array( + 'label' => $ls_breadcrumbs_page_for_posts + ? get_the_title( $ls_breadcrumbs_page_for_posts ) + : __( 'Blog', 'ls-plugin' ), + 'url' => null, + ); + } elseif ( is_category() || is_tag() || is_tax() ) { + $ls_breadcrumbs_term = get_queried_object(); + + if ( $ls_breadcrumbs_term instanceof WP_Term ) { + LS_Plugin_Breadcrumbs::add_taxonomy_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_term ); + + $ls_breadcrumbs_trail[] = array( + 'label' => $ls_breadcrumbs_term->name, + 'url' => null, + ); + } + } elseif ( is_single() ) { + $ls_breadcrumbs_post = get_queried_object(); + + if ( $ls_breadcrumbs_post instanceof WP_Post ) { + $ls_breadcrumbs_post_type = get_post_type( $ls_breadcrumbs_post ); + + if ( is_post_type_hierarchical( $ls_breadcrumbs_post_type ) ) { + LS_Plugin_Breadcrumbs::add_post_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_post ); + } elseif ( 'post' === $ls_breadcrumbs_post_type ) { + $ls_breadcrumbs_categories = get_the_category( $ls_breadcrumbs_post->ID ); + + if ( ! empty( $ls_breadcrumbs_categories ) ) { + $ls_breadcrumbs_primary_term = $ls_breadcrumbs_categories[0]; + + LS_Plugin_Breadcrumbs::add_taxonomy_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_primary_term ); + + $ls_breadcrumbs_trail[] = array( + 'label' => $ls_breadcrumbs_primary_term->name, + 'url' => LS_Plugin_Breadcrumbs::get_term_url( $ls_breadcrumbs_primary_term ), + ); + } + } + + $ls_breadcrumbs_trail[] = array( + 'label' => get_the_title( $ls_breadcrumbs_post ), + 'url' => null, + ); + } + } elseif ( is_page() ) { + $ls_breadcrumbs_post = get_queried_object(); + + if ( $ls_breadcrumbs_post instanceof WP_Post ) { + LS_Plugin_Breadcrumbs::add_post_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_post ); + + $ls_breadcrumbs_trail[] = array( + 'label' => get_the_title( $ls_breadcrumbs_post ), + 'url' => null, + ); + } + } elseif ( is_search() ) { + $ls_breadcrumbs_trail[] = array( + /* translators: %s: search query */ + 'label' => sprintf( __( 'Search results for: %s', 'ls-plugin' ), get_search_query() ), + 'url' => null, + ); + } elseif ( is_404() ) { + $ls_breadcrumbs_trail[] = array( + 'label' => __( 'Page not found', 'ls-plugin' ), + 'url' => null, + ); + } else { + $ls_breadcrumbs_trail[] = array( + 'label' => get_the_archive_title(), + 'url' => null, + ); + } +} + +echo $ls_breadcrumbs_before; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + +$ls_breadcrumbs_last_index = count( $ls_breadcrumbs_trail ) - 1; + +foreach ( $ls_breadcrumbs_trail as $ls_breadcrumbs_index => $ls_breadcrumbs_crumb ) { + if ( $ls_breadcrumbs_index === $ls_breadcrumbs_last_index || empty( $ls_breadcrumbs_crumb['url'] ) ) { + printf( + '%s', + esc_html( $ls_breadcrumbs_crumb['label'] ) + ); + continue; + } + + printf( + '%2$s/', + esc_url( $ls_breadcrumbs_crumb['url'] ), + esc_html( $ls_breadcrumbs_crumb['label'] ) + ); +} + +echo $ls_breadcrumbs_after; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped diff --git a/build/blocks/breadcrumbs/style-index-rtl.css b/build/blocks/breadcrumbs/style-index-rtl.css new file mode 100644 index 0000000..056ef16 --- /dev/null +++ b/build/blocks/breadcrumbs/style-index-rtl.css @@ -0,0 +1 @@ +.ls-crumbs{align-items:center;color:var(--wp--custom--color--text--muted,currentColor);display:flex;flex-wrap:wrap;font-size:.875rem;gap:.35em;line-height:1.4}.ls-crumbs a{color:inherit;text-decoration:underline;text-decoration-color:transparent;transition:text-decoration-color .15s ease}.ls-crumbs a:focus,.ls-crumbs a:hover{text-decoration-color:currentColor}.ls-crumbs i{font-style:normal;opacity:.5}.ls-crumbs span[aria-current=page]{color:var(--wp--custom--color--text--default,currentColor);font-weight:500}.ls-crumbs-placeholder{border:1px dashed;font-style:italic;opacity:.6;padding:.5em .75em} diff --git a/build/blocks/breadcrumbs/style-index.css b/build/blocks/breadcrumbs/style-index.css new file mode 100644 index 0000000..056ef16 --- /dev/null +++ b/build/blocks/breadcrumbs/style-index.css @@ -0,0 +1 @@ +.ls-crumbs{align-items:center;color:var(--wp--custom--color--text--muted,currentColor);display:flex;flex-wrap:wrap;font-size:.875rem;gap:.35em;line-height:1.4}.ls-crumbs a{color:inherit;text-decoration:underline;text-decoration-color:transparent;transition:text-decoration-color .15s ease}.ls-crumbs a:focus,.ls-crumbs a:hover{text-decoration-color:currentColor}.ls-crumbs i{font-style:normal;opacity:.5}.ls-crumbs span[aria-current=page]{color:var(--wp--custom--color--text--default,currentColor);font-weight:500}.ls-crumbs-placeholder{border:1px dashed;font-style:italic;opacity:.6;padding:.5em .75em} diff --git a/inc/class-breadcrumbs.php b/inc/class-breadcrumbs.php new file mode 100644 index 0000000..94000db --- /dev/null +++ b/inc/class-breadcrumbs.php @@ -0,0 +1,99 @@ +taxonomy ) ) { + return; + } + + $ancestor_ids = array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) ); + + foreach ( $ancestor_ids as $ancestor_id ) { + $ancestor_term = get_term( $ancestor_id, $term->taxonomy ); + + if ( $ancestor_term instanceof WP_Term ) { + $trail[] = array( + 'label' => $ancestor_term->name, + 'url' => self::get_term_url( $ancestor_term ), + ); + } + } + } + + /** + * Appends a post's ancestor chain to a breadcrumb trail. + * + * Does not append the post itself — only its ancestors, root-first. + * + * @param array $trail Breadcrumb trail, passed by reference. + * @param WP_Post $post Post whose ancestors should be added. + * @return void + */ + public static function add_post_ancestors( array &$trail, $post ) { + $ancestor_ids = array_reverse( get_post_ancestors( $post ) ); + + foreach ( $ancestor_ids as $ancestor_id ) { + $trail[] = array( + 'label' => get_the_title( $ancestor_id ), + 'url' => get_permalink( $ancestor_id ), + ); + } + } + + /** + * Safely resolves a term's permalink, guarding against WP_Error. + * + * @param WP_Term $term Term to link to. + * @return string|null Term permalink, or null on failure. + */ + public static function get_term_url( $term ) { + $link = get_term_link( $term ); + + return is_wp_error( $link ) ? null : $link; + } +} diff --git a/ls-plugin.php b/ls-plugin.php index 9faaa2e..da205f1 100644 --- a/ls-plugin.php +++ b/ls-plugin.php @@ -50,6 +50,7 @@ function ls_plugin_init() { require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-taxonomy-filter.php'; require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-style-switcher.php'; require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-carousel.php'; + require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-breadcrumbs.php'; require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-scf-json.php'; require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-scf-json-validator.php'; require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-permalinks.php'; @@ -69,6 +70,8 @@ function ls_plugin_init() { new LS_Plugin_Carousel(); + new LS_Plugin_Breadcrumbs(); + // Configure SCF to use plugin-managed Local JSON paths. new LS_Plugin_SCF_JSON(); diff --git a/src/blocks/breadcrumbs/block.json b/src/blocks/breadcrumbs/block.json new file mode 100644 index 0000000..47471f7 --- /dev/null +++ b/src/blocks/breadcrumbs/block.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "ls-plugin/breadcrumbs", + "version": "1.0.0", + "title": "Breadcrumbs", + "category": "theme", + "description": "Displays a breadcrumb navigation trail for the current page, built entirely from core WordPress data.", + "keywords": [ + "breadcrumb", + "breadcrumbs", + "navigation" + ], + "textdomain": "ls-plugin", + "editorScript": "file:./index.js", + "style": "file:./style-index.css", + "supports": { + "html": false, + "align": [ + "wide", + "full" + ], + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + } + }, + "render": "file:./render.php" +} diff --git a/src/blocks/breadcrumbs/edit.js b/src/blocks/breadcrumbs/edit.js new file mode 100644 index 0000000..ebcc351 --- /dev/null +++ b/src/blocks/breadcrumbs/edit.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useBlockProps } from '@wordpress/block-editor'; + +/** + * Edit component for the breadcrumbs block. + * + * This is a dynamic block — the real breadcrumb trail is generated by + * render.php at render time. The editor only needs a static placeholder + * so the block doesn't appear blank or error while inserted. + * + * @param {Object} props Block props. + * @return {Element} Element to render. + */ +export default function Edit() { + const blockProps = useBlockProps( { + className: 'ls-crumbs-placeholder', + } ); + + return ( + + ); +} diff --git a/src/blocks/breadcrumbs/index.js b/src/blocks/breadcrumbs/index.js new file mode 100644 index 0000000..5b4d8d7 --- /dev/null +++ b/src/blocks/breadcrumbs/index.js @@ -0,0 +1,20 @@ +/** + * Registers a new block provided a unique name and an object defining its behavior. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import Edit from './edit.js'; +import save from './save.js'; +import metadata from './block.json'; +import './style.scss'; + +registerBlockType( metadata.name, { + icon: 'admin-links', + edit: Edit, + save, +} ); diff --git a/src/blocks/breadcrumbs/render.php b/src/blocks/breadcrumbs/render.php new file mode 100644 index 0000000..4d05975 --- /dev/null +++ b/src/blocks/breadcrumbs/render.php @@ -0,0 +1,136 @@ + 'ls-crumbs', + 'aria-label' => __( 'Breadcrumb', 'ls-plugin' ), + ) +); + +$ls_breadcrumbs_before = ''; + +$ls_breadcrumbs_trail = array(); + +if ( is_front_page() ) { + $ls_breadcrumbs_trail[] = array( + 'label' => __( 'Home', 'ls-plugin' ), + 'url' => null, + ); +} else { + $ls_breadcrumbs_trail[] = array( + 'label' => __( 'Home', 'ls-plugin' ), + 'url' => home_url( '/' ), + ); + + if ( is_home() ) { + $ls_breadcrumbs_page_for_posts = (int) get_option( 'page_for_posts' ); + $ls_breadcrumbs_trail[] = array( + 'label' => $ls_breadcrumbs_page_for_posts + ? get_the_title( $ls_breadcrumbs_page_for_posts ) + : __( 'Blog', 'ls-plugin' ), + 'url' => null, + ); + } elseif ( is_category() || is_tag() || is_tax() ) { + $ls_breadcrumbs_term = get_queried_object(); + + if ( $ls_breadcrumbs_term instanceof WP_Term ) { + LS_Plugin_Breadcrumbs::add_taxonomy_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_term ); + + $ls_breadcrumbs_trail[] = array( + 'label' => $ls_breadcrumbs_term->name, + 'url' => null, + ); + } + } elseif ( is_single() ) { + $ls_breadcrumbs_post = get_queried_object(); + + if ( $ls_breadcrumbs_post instanceof WP_Post ) { + $ls_breadcrumbs_post_type = get_post_type( $ls_breadcrumbs_post ); + + if ( is_post_type_hierarchical( $ls_breadcrumbs_post_type ) ) { + LS_Plugin_Breadcrumbs::add_post_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_post ); + } elseif ( 'post' === $ls_breadcrumbs_post_type ) { + $ls_breadcrumbs_categories = get_the_category( $ls_breadcrumbs_post->ID ); + + if ( ! empty( $ls_breadcrumbs_categories ) ) { + $ls_breadcrumbs_primary_term = $ls_breadcrumbs_categories[0]; + + LS_Plugin_Breadcrumbs::add_taxonomy_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_primary_term ); + + $ls_breadcrumbs_trail[] = array( + 'label' => $ls_breadcrumbs_primary_term->name, + 'url' => LS_Plugin_Breadcrumbs::get_term_url( $ls_breadcrumbs_primary_term ), + ); + } + } + + $ls_breadcrumbs_trail[] = array( + 'label' => get_the_title( $ls_breadcrumbs_post ), + 'url' => null, + ); + } + } elseif ( is_page() ) { + $ls_breadcrumbs_post = get_queried_object(); + + if ( $ls_breadcrumbs_post instanceof WP_Post ) { + LS_Plugin_Breadcrumbs::add_post_ancestors( $ls_breadcrumbs_trail, $ls_breadcrumbs_post ); + + $ls_breadcrumbs_trail[] = array( + 'label' => get_the_title( $ls_breadcrumbs_post ), + 'url' => null, + ); + } + } elseif ( is_search() ) { + $ls_breadcrumbs_trail[] = array( + /* translators: %s: search query */ + 'label' => sprintf( __( 'Search results for: %s', 'ls-plugin' ), get_search_query() ), + 'url' => null, + ); + } elseif ( is_404() ) { + $ls_breadcrumbs_trail[] = array( + 'label' => __( 'Page not found', 'ls-plugin' ), + 'url' => null, + ); + } else { + $ls_breadcrumbs_trail[] = array( + 'label' => get_the_archive_title(), + 'url' => null, + ); + } +} + +echo $ls_breadcrumbs_before; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + +$ls_breadcrumbs_last_index = count( $ls_breadcrumbs_trail ) - 1; + +foreach ( $ls_breadcrumbs_trail as $ls_breadcrumbs_index => $ls_breadcrumbs_crumb ) { + if ( $ls_breadcrumbs_index === $ls_breadcrumbs_last_index || empty( $ls_breadcrumbs_crumb['url'] ) ) { + printf( + '%s', + esc_html( $ls_breadcrumbs_crumb['label'] ) + ); + continue; + } + + printf( + '%2$s/', + esc_url( $ls_breadcrumbs_crumb['url'] ), + esc_html( $ls_breadcrumbs_crumb['label'] ) + ); +} + +echo $ls_breadcrumbs_after; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped diff --git a/src/blocks/breadcrumbs/save.js b/src/blocks/breadcrumbs/save.js new file mode 100644 index 0000000..d98dfc0 --- /dev/null +++ b/src/blocks/breadcrumbs/save.js @@ -0,0 +1,10 @@ +/** + * Save component for the breadcrumbs block. + * + * Dynamic block — output is generated by render.php. + * + * @return {null} Nothing to save. + */ +export default function save() { + return null; +} diff --git a/src/blocks/breadcrumbs/style.scss b/src/blocks/breadcrumbs/style.scss new file mode 100644 index 0000000..130ca50 --- /dev/null +++ b/src/blocks/breadcrumbs/style.scss @@ -0,0 +1,42 @@ +/** + * Breadcrumbs Block Styles + */ + +.ls-crumbs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35em; + font-size: 0.875rem; + line-height: 1.4; + color: var(--wp--custom--color--text--muted, currentColor); + + a { + color: inherit; + text-decoration: underline; + text-decoration-color: transparent; + transition: text-decoration-color 0.15s ease; + + &:hover, + &:focus { + text-decoration-color: currentColor; + } + } + + i { + font-style: normal; + opacity: 0.5; + } + + span[aria-current="page"] { + color: var(--wp--custom--color--text--default, currentColor); + font-weight: 500; + } +} + +.ls-crumbs-placeholder { + padding: 0.5em 0.75em; + border: 1px dashed currentColor; + opacity: 0.6; + font-style: italic; +}