27 Commits

Author SHA1 Message Date
17728e81d9 Security audit fixes: regex hardening, performance, and code quality (v1.1.2)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m32s
Create Release Package / PHPUnit Tests (push) Successful in 2m35s
Create Release Package / Build Release (push) Successful in 2m36s
- WidgetRenderer: single regex for h2→h4 prevents mismatched tags
- ContextBuilder: O(n) comment tree with parent-indexed lookup map
- ContextBuilder: consolidated sidebar queries into single check
- ContextBuilder: transient caching for sidebar recent posts and tags
- functions.php: hex-to-RGB consolidation, type hints, ctype_xdigit validation
- Transient invalidation hooks for save_post and tag CRUD operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:02:12 +01:00
ea2ccef5de Fix CI build: install Composer deps before npm build (v1.1.1)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m13s
Create Release Package / PHPUnit Tests (push) Successful in 1m8s
Create Release Package / Build Release (push) Successful in 1m58s
The prebuild hook runs phpunit via composer exec, but Composer
dependencies were not installed until after npm run build. Moved
composer install (with dev) before the build step, then reinstall
with --no-dev for the release package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:14:13 +01:00
e607382e11 Add PHPUnit test suite with 64 unit tests (v1.1.1)
Some checks failed
Create Release Package / PHP Lint (push) Successful in 1m6s
Create Release Package / PHPUnit Tests (push) Successful in 1m4s
Create Release Package / Build Release (push) Failing after 1m13s
PHPUnit 11 + Brain\Monkey for WordPress function mocking. Tests cover
BlockRenderer (28), WidgetRenderer (9), NavWalker (14), and
TemplateController (12). Includes functional WP_HTML_Tag_Processor stub,
CI test job between lint and build-release, prebuild hook gating npm
build on passing tests, and release package exclusions for test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:08:34 +01:00
3165e60639 feat: Bootstrap 5 block renderer, widget cards, and sidebar post layout (v1.1.0)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m7s
Create Release Package / Build Release (push) Successful in 1m41s
Add BlockRenderer class injecting Bootstrap classes into 8 core block types
(table, button, buttons, image, search, quote, pullquote, list) via per-block
render_block filters using WP_HTML_Tag_Processor.

Add WidgetRenderer class wrapping sidebar widgets in Bootstrap card components
with h4 heading hierarchy via dynamic_sidebar_params and widget_block_content
filters.

Add widget SCSS stylesheet for list styling, search input-group, tag cloud
pills, and card-flush list positioning.

Add single-sidebar.html.twig as the default post template with two-column
Bootstrap layout (col-lg-8 content, col-lg-4 sidebar). Full-width available
via template selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:43:43 +01:00
9904bf508a fix: scope admin bar offcanvas padding to mobile viewports (v1.0.12)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m8s
Create Release Package / Build Release (push) Successful in 1m44s
Wrap the offcanvas padding-top rule in a max-width: 991.98px media query
so it only applies when the offcanvas is active, not on wide screens
where the navbar renders inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:43:34 +01:00
77778860ab feat: offcanvas mobile navigation with user avatar and admin bar fix (v1.0.11)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m13s
Create Release Package / Build Release (push) Successful in 1m56s
Switch mobile nav from collapse to offcanvas, add logged-in user avatar
and My Account link to offcanvas header, move dark mode toggle to
offcanvas footer. Fix admin bar overlapping offcanvas via inline CSS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:38:42 +01:00
0902c5e1a5 fix: decode WordPress title entities before Twig to prevent double-encoding (v1.0.10)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m10s
Create Release Package / Build Release (push) Successful in 1m50s
WordPress's get_the_title() pre-encodes & as &#038;. Twig autoescape
re-encoded the & in &#038; to &amp;#038;, rendering as literal &#038;
in the browser. Wrapped all 6 get_the_title() calls in ContextBuilder
with wp_specialchars_decode() so Twig can properly re-encode once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:20:19 +01:00
1a0a1fa63a i18n: add full translations for 13 locales (v1.0.10)
- Regenerated wp-bootstrap.pot with updated extractable strings
- Translated 13 locales: de_CH, de_CH_informal, de_DE, de_DE_informal,
  en_GB, es_ES, fr_CH, fr_FR, it_CH, it_IT, nl_NL, pl_PL, pt_PT
- German variants: Swiss (ss) vs Standard (ß), formal (Sie) vs informal (du)
- All 359 translatable strings covered per locale
- Documented fast translation workflow in CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 12:26:36 +01:00
576922160e perf: color variation CSS transient caching and Twig auto_reload fix (v1.0.9)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 2m3s
Create Release Package / Build Release (push) Successful in 2m5s
- functions.php: cache wp_bootstrap_variation_colors() output in a 24-hour
  transient keyed by md5(get_stylesheet()); invalidate on switch_theme and
  save_post_wp_global_styles so Design Editor changes apply immediately
- TwigService.php: change auto_reload from hardcoded true to WP_DEBUG so
  Twig stops stat()-ing compiled template files on every production request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 18:26:40 +01:00
89afa00678 security: OWASP audit and hardening (v1.0.8)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m8s
Create Release Package / Build Release (push) Successful in 1m53s
- Archive XSS: wrap get_the_archive_title/description with wp_kses_post()
  in ContextBuilder to sanitize Editor-editable term content rendered via |raw
- Comment fields: esc_html() on comment_author, esc_url() on comment_author_url
  at data source; template updated to output pre-escaped URL via |raw
- dark-mode.js: whitelist localStorage value against ['dark','light'] to
  prevent attribute injection from third-party script tampering
- TwigService: add is_safe=>html to esc_html/esc_attr/esc_url Twig functions
  to prevent double-encoding if autoescape is ever enabled
- Add .markdownlint.json (disable MD024 duplicate headings, MD013 line length)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:23:33 +01:00
876be4a041 feat: register do_shortcode() as Twig function (v1.0.7)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m4s
Create Release Package / Build Release (push) Successful in 1m51s
Adds do_shortcode to TwigService::registerWordPressFunctions() so child
themes and partials can render WordPress shortcodes directly inside Twig
templates via {{ do_shortcode('[shortcode]') }}.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 15:06:15 +01:00
59b79d23df use the twig footer instead of FSE editor 2026-02-15 18:50:34 +01:00
e7decbe96b fix: populate sidebar context for pages using Page with Sidebar template, use block_template_part for footer (v1.0.6)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 57s
Create Release Package / Build Release (push) Successful in 1m29s
- ContextBuilder now calls getSidebarData() when page template slug is
  'page-sidebar', fixing empty sidebar on pages with that template
- Added block_template_part() Twig function to TwigService for FSE
  Template Editor compatibility
- Changed footer rendering from include to block_template_part() so
  footer edits in the Template Editor take effect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:44:45 +01:00
815f6fa19e fix: standardize translation file names and add 11 new locales (v1.0.5)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 58s
Create Release Package / Build Release (push) Successful in 1m26s
- Rename all .po files to use wp-bootstrap- prefix (WordPress convention)
- Add 11 new locale translations (de_CH_informal, de_DE, de_DE_informal,
  en_GB, es_ES, fr_CH, it_CH, it_IT, nl_NL, pl_PL, pt_PT)
- Total: 13 locales + en_US base = 14 supported languages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:51:18 +01:00
b285d75878 feat: add wp_bootstrap_should_render_template filter for plugin decoupling (v1.0.4)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m29s
Create Release Package / Build Release (push) Successful in 1m40s
Allows plugins and child themes to prevent the theme's TemplateController
from rendering specific requests, enabling clean separation of concerns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-11 11:48:08 +01:00
e3e9b9f2be fix: make page title <h1> conditional to prevent double headings (v1.0.3)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m3s
Create Release Package / Build Release (push) Successful in 1m41s
When plugins inject content via TwigService with empty post.title,
the theme's <h1> is now skipped. Prevents duplicate headings on
plugin-rendered pages that provide their own titles.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-11 09:54:16 +01:00
702c0c35f4 fix: add title-tag theme support for proper <title> output (v1.0.2)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m14s
Create Release Package / Build Release (push) Successful in 1m42s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-10 16:00:10 +01:00
3620d9b1d1 v1.0.1 - Integrate Bootstrap Icons web font
All checks were successful
Create Release Package / PHP Lint (push) Successful in 55s
Create Release Package / Build Release (push) Successful in 1m35s
Add bootstrap-icons npm package with SCSS import and font file copy
build step. All 2,000+ icons available via CSS classes (bi bi-*) in
both frontend and block editor.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-09 09:28:26 +01:00
5268289782 v1.0.0 - Release: widget area, documentation refresh
All checks were successful
Create Release Package / PHP Lint (push) Successful in 50s
Create Release Package / Build Release (push) Successful in 1m14s
- Register sidebar widget area via register_sidebar()
- Render WordPress widgets in Twig sidebar with fallback to built-in content
- Update README.md with accurate feature counts and descriptions
- Update translation files with widget area strings
- Bump version to 1.0.0

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 18:43:09 +01:00
4c808a992a v0.3.3 - Fix style variation bridge to read from theme palette origin
All checks were successful
Create Release Package / PHP Lint (push) Successful in 50s
Create Release Package / Build Release (push) Successful in 1m16s
WordPress puts active variation colors in the 'theme' palette origin,
not 'custom'. Detection now compares theme origin against base defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 18:12:52 +01:00
d6731cca47 v0.3.2 - Fix dark mode conflicts with WordPress global styles
All checks were successful
Create Release Package / PHP Lint (push) Successful in 59s
Create Release Package / Build Release (push) Successful in 1m25s
Fix dark mode body colors overridden by WordPress theme.json styles.color,
add broad dark mode rules for plugin form elements, fix footer-columns
template, and add style variation bridge function.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 17:33:12 +01:00
edf053d7ea v0.3.1 - Add 11 new style variations (4 light, 4 dark + 3 earlier dark)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 50s
Create Release Package / Build Release (push) Successful in 1m19s
Add Rose, Sand, Lavender, Mint (light) and Ember, Arctic, Amethyst,
Slate, Mocha, Nebula, Obsidian (dark) color palettes for the Design
Editor. Total of 15 style variations available (7 light, 7 dark).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 16:37:29 +01:00
eb5ac6f7ad v0.3.0 - Polish: accessibility, security, performance, RTL, French translation
All checks were successful
Create Release Package / PHP Lint (push) Successful in 52s
Create Release Package / Build Release (push) Successful in 1m21s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 16:27:13 +01:00
cc8dc9d357 v0.2.0 - Design Editor: templates, patterns, header/footer variations
All checks were successful
Create Release Package / PHP Lint (push) Successful in 57s
Create Release Package / Build Release (push) Successful in 1m23s
Full Design Editor compatibility with custom block categories, page templates,
header/footer variations, and navigation styles. Both FSE (admin) and Twig
(frontend) sides kept in sync.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 16:05:29 +01:00
cb288d6e74 v0.1.1 - Bootstrap frontend rendering via Twig templates
All checks were successful
Create Release Package / PHP Lint (push) Successful in 49s
Create Release Package / Build Release (push) Successful in 1m18s
Replace FSE block markup on the frontend with proper Bootstrap 5 HTML
rendered through Twig templates. The Site Editor remains functional for
admin editing while the public site outputs Bootstrap navbar, cards,
pagination, grid layout, and responsive components.

New PHP classes: TemplateController, ContextBuilder, NavWalker
New Twig templates: 20 files (base, pages, partials, components)
Enhanced TwigService with WordPress functions and globals

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 15:11:00 +01:00
d069a203b4 Remove yarn, use npm exclusively
Replace all yarn references with npm across README, CLAUDE.md,
CHANGELOG, .gitignore, and CI workflow. Remove yarn.lock.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 03:20:25 +01:00
0847f5a2d0 Update CLAUDE.md with v0.1.0 session learnings
Add build pipeline docs, architecture notes, session history,
and CI/CD learnings. Clean up completed roadmap items.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-08 03:17:46 +01:00
128 changed files with 64369 additions and 2264 deletions

View File

@@ -24,10 +24,31 @@ jobs:
run: |
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
test:
name: PHPUnit Tests
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, dom
tools: composer:v2
- name: Install Composer dependencies
run: composer install --no-interaction
- name: Run PHPUnit
run: composer exec -- phpunit
build-release:
name: Build Release
runs-on: ubuntu-latest
needs: [lint]
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -54,16 +75,19 @@ jobs:
- name: Install Node dependencies
run: npm install
- name: Build assets
run: npm run build
- name: Validate composer.json
run: composer validate --no-check-lock --no-check-all
- name: Install Composer dependencies (production)
- name: Install Composer dependencies (with dev for prebuild tests)
run: |
composer config platform.php 8.3.0
composer install --no-dev --optimize-autoloader --no-interaction
composer install --no-interaction
- name: Build assets
run: npm run build
- name: Reinstall Composer dependencies (production only)
run: composer install --no-dev --optimize-autoloader --no-interaction
- name: Install gettext
run: apt-get update && apt-get install -y gettext
@@ -113,7 +137,6 @@ jobs:
-x "${THEME_NAME}/src/*" \
-x "${THEME_NAME}/package.json" \
-x "${THEME_NAME}/package-lock.json" \
-x "${THEME_NAME}/yarn.lock" \
-x "${THEME_NAME}/composer.json" \
-x "${THEME_NAME}/composer.lock" \
-x "${THEME_NAME}/.gitignore" \
@@ -121,6 +144,9 @@ jobs:
-x "${THEME_NAME}/*.log" \
-x "${THEME_NAME}/*.po~" \
-x "${THEME_NAME}/*.bak" \
-x "${THEME_NAME}/tests/*" \
-x "${THEME_NAME}/phpunit.xml.dist" \
-x "${THEME_NAME}/.phpunit.cache/*" \
-x "${THEME_NAME}/views/.gitkeep" \
-x "${THEME_NAME}/assets/images/.gitkeep" \
-x "*.DS_Store"
@@ -188,6 +214,14 @@ jobs:
echo "src/ excluded: OK"
fi
# Verify tests excluded
if unzip -l "releases/${THEME_NAME}-${VERSION}.zip" | grep -q "${THEME_NAME}/tests/"; then
echo "WARNING: tests/ directory should be excluded"
exit 1
else
echo "tests/ excluded: OK"
fi
- name: Extract changelog for release notes
id: changelog
run: |

10
.gitignore vendored
View File

@@ -15,15 +15,21 @@ vendor/
.DS_Store
Thumbs.db
# Yarn
yarn-error.log
# npm
npm-debug.log
# Backup files
*.bak
*.po~
# Compiled translations (built by CI/CD release workflow)
*.mo
# Claude local settings
.claude/settings.local.json
# PHPUnit cache
.phpunit.cache/
# Build artifacts (releases directory)
releases/

4
.markdownlint.json Normal file
View File

@@ -0,0 +1,4 @@
{
"MD024": false,
"MD013": false
}

View File

@@ -2,6 +2,283 @@
All notable changes to this project will be documented in this file.
## [1.1.2] - 2026-03-01
### Security
- **WidgetRenderer regex hardening**: Combined two separate `preg_replace` calls for h2→h4 heading downgrade into a single regex that only matches `<h2>` elements with the `wp-block-heading` class. The previous approach replaced all `</h2>` tags unconditionally, risking mismatched tags if a widget contained non-block h2 elements.
### Performance
- **O(n) comment tree building** (`ContextBuilder`): Replaced O(n²) recursive scan with a parent-indexed lookup map built in a single pass. Each recursion level now iterates only direct children instead of all comments.
- **Consolidated sidebar queries** (`ContextBuilder`): Merged three separate sidebar detection branches (`is_home`, `is_page`+sidebar, `is_singular` post) into a single boolean check with one `getSidebarData()` call, eliminating up to 2 redundant calls per request.
- **Transient caching for sidebar data** (`ContextBuilder`): `getSidebarRecentPosts()` and `getSidebarTags()` results cached in WordPress transients (1 hour TTL). Invalidation hooks on `save_post` (recent posts) and `create/edit/delete_post_tag` (tags).
### Changed
- **Hex-to-RGB consolidation** (`functions.php`): `wp_bootstrap_hex_to_rgb()` now delegates to `wp_bootstrap_hex_to_rgb_array()` instead of duplicating hex parsing logic. Added `ctype_xdigit()` validation and return type hints to all color utility functions.
## [1.1.1] - 2026-02-28
### Added
- **PHPUnit test suite**: 64 unit tests covering `BlockRenderer`, `WidgetRenderer`, `NavWalker`, and `TemplateController` classes with 107 assertions. Uses PHPUnit 11 and Brain\Monkey for WordPress function mocking.
- **Test infrastructure**: `WP_HTML_Tag_Processor` functional stub using DOMDocument for testing block renderer HTML manipulation outside WordPress. Empty stubs for `WP_Block` and `WP_Widget` type hints.
- **Build pipeline integration**: Tests run automatically before every `npm run build` via `prebuild` hook (`composer exec -- phpunit`).
- **CI test job**: New PHPUnit test step in Gitea CI workflow between lint and build-release. Tests must pass before release packages are built.
- **Release package exclusions**: `tests/`, `phpunit.xml.dist`, and `.phpunit.cache/` excluded from release ZIP packages with verification step.
## [1.1.0] - 2026-02-28
### Added
- **Block Renderer** (`inc/Block/BlockRenderer.php`): New class that injects Bootstrap 5 classes into WordPress core block HTML output on the frontend via per-block `render_block_{$name}` filters. Handles 8 block types:
- `core/table``.table` on `<table>`, `.table-striped` when stripes style is active
- `core/button``.btn` + `.btn-{variant}` or `.btn-outline-{variant}` mapped from WP preset color slugs
- `core/buttons``.d-flex .flex-wrap .gap-2` on button group wrapper
- `core/image``.img-fluid` on `<img>` for responsive images
- `core/search``.input-group` on inner wrapper, `.form-control` on input, `.btn .btn-primary` on button
- `core/quote``.blockquote` on `<blockquote>`, `.blockquote-footer` on `<cite>`
- `core/pullquote` — Same blockquote treatment inside `<figure>`
- `core/list``.list-group` + `.list-group-item` when `is-style-list-group` block style is selected
- **Widget Renderer** (`inc/Block/WidgetRenderer.php`): New class that transforms sidebar widgets into Bootstrap 5 card components via `dynamic_sidebar_params` and `widget_block_content` filters. Wraps each widget in a `.card > .card-body` structure with `.card-title` headings. Downgrades block widget `<h2>` headings to `<h4>` for proper sidebar visual hierarchy.
- **Widget SCSS** (`src/scss/_widgets.scss`): New stylesheet for sidebar widget Bootstrap styling — list-group-style list items with border separators, flush-to-card-edge list positioning, Bootstrap form-control styling for select dropdowns, search form input-group layout, tag cloud with pill badges, and secondary-color post dates.
- **List Group block style**: New "List Group" style registered for `core/list` blocks — applies Bootstrap `.list-group` and `.list-group-item` classes when selected in the editor.
- **Single post sidebar template** (`views/pages/single-sidebar.html.twig`): New two-column layout for blog posts with `col-lg-8` content area and `col-lg-4` sidebar. Includes all single post features (meta, thumbnail, tags, post navigation, comments, more posts). "More posts" section uses `row-cols-md-2` to fit the narrower column.
- **Extensibility**: `wp_bootstrap_block_renderer_blocks` filter allows child themes to add/remove block handler mappings.
### Changed
- **Post template default** (`inc/Template/TemplateController.php`): Blog posts now render with the sidebar layout by default (`single-sidebar.html.twig`). Posts assigned the "Full Width" template use `single.html.twig` instead. Template selection uses `get_page_template_slug()` with a `match` expression.
- **Sidebar data for posts** (`inc/Template/ContextBuilder.php`): Posts always receive sidebar data (recent posts, tags, widgets) regardless of template selection, ensuring the sidebar partial always has data available.
- **Widget SCSS import** (`src/scss/style.scss`): Added `_widgets` partial import between Bootstrap Icons and custom styles.
## [1.0.12] - 2026-02-28
### Fixed
- **Admin bar offcanvas padding on desktop** (`functions.php`): Scoped the admin bar offcanvas padding fix to mobile viewports only (`max-width: 991.98px`) so the extra padding does not appear on wide screens where the offcanvas renders inline as a regular navbar.
## [1.0.11] - 2026-02-28
### Changed
- **Offcanvas mobile navigation**: Default header now uses `header-offcanvas.html.twig` instead of `header.html.twig`. Mobile navigation slides in as an offcanvas panel from the right instead of collapsing downward.
- **User avatar in offcanvas header**: When logged in, the offcanvas header displays the user's Gravatar and display name linking to the WooCommerce My Account page (or WP admin profile as fallback). Falls back to the site name when logged out.
- **Dark mode toggle repositioned**: Moved from the offcanvas body to the offcanvas footer on mobile. Desktop toggle remains in the navbar.
### Added
- **User context data** (`inc/Template/ContextBuilder.php`): New `getUserData()` method exposing `user.logged_in`, `user.display_name`, `user.avatar`, and `user.account_url` to all Twig templates.
### Fixed
- **Admin bar overlapping offcanvas** (`functions.php`): Inline CSS via `wp_add_inline_style()` adds `padding-top` matching the admin bar height to `.offcanvas` when the admin bar is visible, preventing content overlap.
## [1.0.10] - 2026-02-25
### Fixed
- **Title double-encoding in Twig templates** (`inc/Template/ContextBuilder.php`): WordPress's `get_the_title()` pre-encodes `&` as `&#038;`. When passed to Twig with autoescape enabled, the `&` in `&#038;` was escaped again to `&amp;#038;`, rendering as literal `&#038;` in the browser (e.g. "Bewerbungen &#038; Nachrichten" instead of "Bewerbungen & Nachrichten"). Fixed by wrapping all 6 `get_the_title()` calls with `wp_specialchars_decode()` to decode WordPress entities before Twig. Twig autoescape then properly re-encodes `&``&amp;`. This is XSS-safe because Twig still escapes all output.
## [1.0.9] - 2026-02-19
### Performance
- **Color variation CSS transient caching** (`functions.php`): `wp_bootstrap_variation_colors()` now caches the generated inline CSS in a 24-hour WordPress transient keyed by `wp_bootstrap_variation_css_` + an MD5 of the active stylesheet slug. Previously the palette iteration and CSS string building ran on every frontend page load. The transient is immediately invalidated on `switch_theme` and `save_post_wp_global_styles`, so changes made via the Design Editor are reflected instantly.
- **Twig template recompilation gated behind `WP_DEBUG`** (`inc/Twig/TwigService.php`): `auto_reload` in the Twig `Environment` constructor was hardcoded to `true`, causing Twig to stat every compiled template file on every request to check for source changes. Changed to `WP_DEBUG` so template recompilation only occurs during development. In production (`WP_DEBUG = false`) compiled Twig templates are served from cache without filesystem mtime checks.
## [1.0.8] - 2026-02-19
### Security
- **Archive XSS hardening**: `ContextBuilder::getArchiveData()` now wraps `get_the_archive_title()` and `get_the_archive_description()` with `wp_kses_post()`. Term descriptions are user-editable by Editors and above; without sanitization an injected `<script>` tag would execute via the `|raw` filter in `archive.html.twig`
- **Comment author XSS hardening**: `ContextBuilder::buildCommentTree()` now applies `esc_html()` to `comment_author` and `esc_url()` to `comment_author_url` at the data source, preventing injection via user-supplied comment fields
- **Dark mode localStorage whitelist**: `getPreferredTheme()` in `dark-mode.js` now validates the stored theme value against `['dark', 'light']` before use, preventing attribute injection from a tampered localStorage value written by a third-party script
- **Twig escaping functions marked safe**: `esc_html()`, `esc_attr()`, and `esc_url()` registered in `TwigService` are now declared with `['is_safe' => ['html']]`, preventing double-encoding if Twig autoescape is ever enabled
### Changed
- `views/partials/comment-item.html.twig`: Comment author URL now output via `{{ comment.author_url|raw }}` (escaped in PHP) instead of calling `esc_url()` from the template, keeping escaping logic in one place
## [1.0.7] - 2026-02-18
### Added
- `do_shortcode()` registered as a Twig function in `TwigService`, allowing shortcodes to be rendered directly from Twig templates via `{{ do_shortcode('[shortcode]') }}`
## [1.0.6] - 2026-02-14
### Fixed
- Sidebar widgets not rendered on pages using the "Page with Sidebar" template — `ContextBuilder::build()` only populated `sidebar` context for `is_home()`, so `page-sidebar.html.twig` received no widget data
## [1.0.5] - 2026-02-11
### Added
- 11 new translation files: de_CH_informal, de_DE, de_DE_informal, en_GB, es_ES, fr_CH, it_CH, it_IT, nl_NL, pl_PL, pt_PT (total: 13 locales + en_US base)
- Compiled .mo files for all 13 translations
### Changed
- Standardized all .po file names to use `wp-bootstrap-` prefix (WordPress convention: `{text-domain}-{locale}.po`)
## [1.0.4] - 2026-02-11
### Added
- `wp_bootstrap_should_render_template` filter in `TemplateController::render()` — allows plugins and child themes to prevent the theme from rendering a specific request, enabling clean separation of concerns when plugins handle their own page rendering
## [1.0.3] - 2026-02-11
### Fixed
- Double `<h1>` headings on pages where plugins provide their own titles — `page.html.twig` now wraps `<h1>` in `{% if post.title is not empty %}` guard so plugins can suppress it by passing empty `post.title`
## [1.0.2] - 2026-02-10
### Fixed
- Missing HTML `<title>` tag on all pages — theme never declared `add_theme_support('title-tag')`, so WordPress's `_wp_render_title_tag()` hook was inactive during `wp_head()` output in Twig templates
## [1.0.1] - 2026-02-09
### Added
- Bootstrap Icons web font integration — all 2,000+ icons available via `<i class="bi bi-*"></i>` CSS classes
- `copy:icons` build step to copy icon font files (`.woff`, `.woff2`) from `node_modules` to `assets/fonts/`
- Bootstrap Icons SCSS imported in both frontend and editor stylesheets for icon support in templates and block editor
## [1.0.0] - 2026-02-08
### Added
- Sidebar widget area (`primary-sidebar`) registered via `register_sidebar()` — manageable in Appearance > Widgets
- Widget area rendering in Twig sidebar with fallback to built-in content (recent posts, search, tags) when no widgets assigned
- Widget area description strings added to all translation files (en_US, de_CH, fr_FR)
### Changed
- Updated README.md with accurate feature counts (15 style variations, 41 patterns, 3 translations)
- Added documentation for style variation bridge, widget areas, RTL support, and accessibility features
## [0.3.3] - 2026-02-08
### Fixed
- Style variation colors not applied to Bootstrap frontend — bridge function checked wrong palette origin (`custom` instead of `theme`)
- Variation detection now compares `theme` origin against base theme.json defaults instead of looking for slugs in `custom` origin
## [0.3.2] - 2026-02-08
### Fixed
- Dark mode body colors overridden by WordPress global styles (`styles.color` in `theme.json` generated conflicting `body` CSS)
- Dark mode styling for plugin-generated form elements (`select`, `input`, `textarea`) that lack Bootstrap classes
- Footer columns template used hardcoded `bg-dark text-light` instead of semantic `bg-body-tertiary`
- Style variation bridge function ran with default palette when no variation was active, causing unnecessary CSS overrides
### Changed
- Removed `styles.color` from `theme.json` to prevent WordPress from generating body background/text CSS that conflicts with Bootstrap dark mode
- Added `!important` override in `_custom.scss` for `html[data-bs-theme="dark"] body` to ensure Bootstrap dark mode takes precedence
- Added broad dark mode rules for native form elements in `_custom.scss`
## [0.3.1] - 2026-02-08
### Added
- 4 new light style variations: Rose, Sand, Lavender, Mint
- 4 new dark style variations: Slate, Mocha, Nebula, Obsidian
- Total of 15 color palettes available in the Design Editor (7 light, 7 dark, plus default)
## [0.3.0] - 2026-02-08
### Added
- Skip-to-content link for keyboard navigation accessibility
- ARIA labels on all navigation landmarks (`<nav>`, `<aside>`)
- `aria-current="page"` on active dropdown menu items across all header variants
- Lazy loading (`loading="lazy"`) on all below-fold images (post thumbnails, cards, avatars)
- Screen reader announcement for dark mode toggle via `aria-live` status region
- Font preload hints for Inter and Lora variable fonts
- RTL language support: conditional RTL stylesheet, logical CSS properties for blockquote and list styles
- French (fr_FR) translation with all ~216 strings translated
- CSS classes for post thumbnails, card thumbnails, sidebar headings, and hero overlays (replacing inline styles)
### Fixed
- XSS vulnerability in search results template (`search_query` now escaped with `|e('html')`)
- Comment author URLs now explicitly escaped with `esc_url()` in Twig templates
### Changed
- Block style `blockquote-accent` uses `border-inline-start` instead of `border-left` for RTL compatibility
- Block style `list-unstyled` uses `padding-inline-start` instead of `padding-left` for RTL compatibility
- Inline styles in Twig templates replaced with CSS classes for maintainability
- Updated translation files (`.pot` and `de_CH.po`) with new accessibility strings
- Build pipeline includes RTL SCSS compilation step
## [0.2.0] - 2026-02-08
### Added
- Full Design Editor compatibility: Bootstrap JS in block editor, full Bootstrap SCSS in editor stylesheet
- 3 custom block categories: Bootstrap Layout, Bootstrap Components, Bootstrap Navigation
- 3 custom pattern categories: Layout, Components, Navigation
- 4 layout patterns: container, 2-column, 3-column, full-width section
- 2 component patterns: card group, accordion
- 3 full-page patterns: about, services, contact
- 4 custom page templates (FSE + Twig): landing (no header/footer), full-width, hero, sidebar
- 2 header variations (FSE + Twig): centered, transparent
- 2 footer variations (FSE + Twig): minimal, multi-column
- 2 navigation patterns: dark navbar, offcanvas mobile navigation
- Offcanvas navigation Twig partial with Bootstrap offcanvas component
- Editor SCSS overrides for alignment and spacing consistency
- Shadow presets (sm, md, lg), aspect ratio presets, and custom layout values in `theme.json`
- Header/footer variant support via `get_theme_mod()` in `ContextBuilder`
- Twig block inheritance in `base.html.twig` for header/footer variant overrides
- Custom page template routing via `get_page_template_slug()` in `TemplateController`
- Transparent header and offcanvas dark mode SCSS styles
### Changed
- Enhanced `editor-style.scss` to import full Bootstrap for WYSIWYG editor fidelity
- Enhanced `theme.json` with custom templates and template parts registration
- Enhanced `functions.php` with block editor assets, block categories, and pattern categories
- Enhanced `ContextBuilder` with header/footer variant methods
- Enhanced `TemplateController` with custom page template slug routing
- Updated translation files (`.pot` and `de_CH.po`) with all new translatable strings
## [0.1.1] - 2026-02-08
### Added
- Twig-based frontend rendering via `template_redirect` hook, bypassing FSE block markup on the frontend while preserving Site Editor functionality
- `TemplateController` class: resolves and renders Twig templates for all page types (home, single, page, archive, search, 404)
- `ContextBuilder` class: gathers WordPress data (posts, menus, pagination, comments, sidebar, archive info) into structured arrays for Twig
- `NavWalker` class: converts flat `wp_get_nav_menu_items()` into nested tree for Bootstrap dropdown menus
- 20 Twig templates with proper Bootstrap 5 HTML: base layout, 5 page templates, 9 partials (header, footer, pagination, sidebar, comments, search form, dark mode toggle, meta, post navigation), 3 components (post card, post grid card, post loop)
- Bootstrap 5 navbar with responsive collapse, brand, dropdown support, and dark mode toggle
- Bootstrap 5 card components for post listings
- Bootstrap 5 pagination component
- Bootstrap 5 comment section with threaded replies and Bootstrap-styled form fields
- Bootstrap 5 sidebar with recent posts, search, and tag cloud (badges)
- Previous/next post navigation and "More posts" grid on single posts
- WordPress functions in Twig: `wp_head`, `wp_footer`, `wp_body_open`, `language_attributes`, `body_class`, `home_url`, `get_bloginfo`, `get_search_query`, `wp_kses_post`, `number_format_i18n`, `_n`
- Twig globals: `site_name`, `site_description`, `site_url`, `theme_uri`, `charset`, `current_year`
- Twig filters: `wpautop`, `wp_kses_post`
- `primary` and `footer` navigation menu locations
- Comment form fields filter for Bootstrap classes (`form-control`, `form-label`, `form-check`, `btn`)
- Fallback menu from published pages when no menu is assigned
- Sidebar layout detection for "Blog with Sidebar" template
- README.md with project documentation
### Changed
- Enhanced `TwigService` with WordPress output-buffering functions, globals, and filters
## [0.1.0] - 2026-02-08
### Added
@@ -27,7 +304,7 @@ All notable changes to this project will be documented in this file.
### Added
- Initial theme scaffolding
- Bootstrap 5 CSS and JS integration (served locally via Yarn)
- Bootstrap 5 CSS and JS integration (served locally via npm)
- SASS build pipeline with Dart Sass, PostCSS, Autoprefixer, cssnano
- Twig 3.0 template engine integration via Composer
- FSE templates: index, home, single, page, archive, search, 404

582
CLAUDE.md
View File

@@ -34,19 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
### Getting started (v0.0.1)
- [ ] Create a new Theme, use `wp-core/wp-content/themes/twentytwentyfive/` as blueprint (but not as base-theme, we're building from scratch!).
- [ ] Include Bootstrap 5 (CSS/JS) (serve locally, not from CDN, use Yarn for install).
- [ ] Use SASS as Stylesheet language, use node-sass for compilation.
- [ ] Integrate custom stylesheets using SASS.
- [ ] Add Gitea CI/CD workflow to create WordPress compatible release packages. Perform as much as possible build tasks in the workflow.
- [ ] Create `PLAN.md` with a comprhensive plan to create the custom theme.
- [ ] Create `README.md` with current information.
- [ ] Create `CHANGELOG.md` with initial information
- [ ] Commit to `main` and `dev`, but don't tag yet. Push to `origin`.
Planning is moved to `PLAN.md`
Current version is **v1.1.2**. See `PLAN.md` for details.
## Technical Stack
@@ -56,7 +44,7 @@ Planning is moved to `PLAN.md`
- **Template Engine:** Twig 3.0 (via Composer)
- **Frontend:** Bootstrap 5 Javascript & Vanilla JavaScript
- **Styling:** Bootstrap 5 & Custom CSS (if necessary)
- **Dependency Management:** Composer (PHP), Yarn (JS/CSS)
- **Dependency Management:** Composer (PHP), npm (JS/CSS)
- **Internationalization:** WordPress i18n (.pot/.po/.mo files)
- **Canonical Plugin Name:** `wp-bootstrap`
@@ -89,8 +77,49 @@ Text domain: `wp-bootstrap`
- `en_US` - English (United States) [base language - .pot template]
- `de_CH` - German (Switzerland, formal)
- `de_CH_informal` - German (Switzerland, informal)
- `de_DE` - German (Germany, formal)
- `de_DE_informal` - German (Germany, informal)
- `en_GB` - English (United Kingdom)
- `es_ES` - Spanish (Spain)
- `fr_CH` - French (Switzerland)
- `fr_FR` - French (France)
- `it_CH` - Italian (Switzerland)
- `it_IT` - Italian (Italy)
- `nl_NL` - Dutch (Netherlands)
- `pl_PL` - Polish (Poland)
- `pt_PT` - Portuguese (Portugal)
There is no need to compile translation to *.mo locally as it will be done in the Gitea CD/CI pipeline
Translation file naming convention: `wp-bootstrap-{locale}.po` (e.g., `wp-bootstrap-de_CH.po`)
Compiled .mo files are built by the Gitea CI/CD pipeline during releases. For local development:
```bash
for po in languages/wp-bootstrap-*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
```
#### Updating Translations
When new strings are added to PHP sources, use the fast JSON workflow documented in
`wp-jobroom-theme/CLAUDE.md → Updating Translations (Fast JSON Workflow)`. That
document contains the full step-by-step process including the `patch-po.py` patcher script
(located in `wp-jobroom-theme/languages/patch-po.py`) which patches **both** `wp-bootstrap`
and `wp-jobroom-theme` `.po` files in a single pass.
**Quick reference for wp-bootstrap POT regeneration:**
```bash
docker exec jobroom-wordpress wp i18n make-pot \
/var/www/html/wp-content/themes/wp-bootstrap \
/var/www/html/wp-content/themes/wp-bootstrap/languages/wp-bootstrap.pot \
--allow-root
# Then merge into all .po files:
for locale in de_CH de_CH_informal de_DE de_DE_informal en_GB es_ES fr_CH fr_FR it_CH it_IT nl_NL pl_PL pt_PT; do
msgmerge --update --backup=none --no-fuzzy-matching \
languages/wp-bootstrap-${locale}.po languages/wp-bootstrap.pot
done
```
### Create Releases
@@ -170,3 +199,526 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
6. **MD032 - Lists should be surrounded by blank lines**: Add a blank line before AND after list blocks, including after bold labels like `**Attributes:**`.
7. **MD034 - Bare URLs**: Wrap URLs in angle brackets (e.g., `<https://example.com>`) or use markdown link syntax `[text](url)`.
8. **Author section formatting**: Use a heading (`### Name`) instead of bold (`**Name**`) for the author name to maintain consistent document structure.
### Build Pipeline
The build uses npm scripts defined in `package.json`:
```bash
# Full build (CI and local)
npm run build
# Development watch mode
npm run dev
```
Build steps (in order):
1. `copy:js` — Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
2. `copy:theme-js` — Copy theme JS (e.g., `dark-mode.js`) from `src/js/` to `assets/js/`
3. `scss` — Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
4. `postcss` — Autoprefixer + cssnano minification → `assets/css/style.min.css`
**CI/CD note:** The Gitea workflow uses `npm install` and `npm run build`.
### Architecture Notes
- **Dark mode:** Uses Bootstrap 5.3 `data-bs-theme` attribute on `<html>`. An inline anti-flash script runs synchronously in `<head>` (via `wp_add_inline_script` with `'before'`), while the full `dark-mode.js` is deferred. Preference stored in `localStorage` key `wp-bootstrap-theme`.
- **Block styles:** Registered via `register_block_style()` with `inline_style` parameter in `functions.php`. Dark mode overrides for alert/card styles are in `src/scss/_custom.scss`.
- **Style variations:** JSON files in `styles/` directory. All 15 variations (7 light, 7 dark, plus default) use the same 10 color slug names (base, contrast, primary, secondary, success, danger, warning, info, light, dark) to ensure patterns work across all schemes.
- **Fonts:** Inter (sans-serif) and Lora (serif) variable fonts bundled as `.woff2` in `assets/fonts/`. Declared via `fontFace` in `theme.json` with `font-display: swap`.
- **Patterns:** PHP files in `patterns/` with WordPress block markup and i18n. Hidden patterns (prefixed `hidden-`) are reusable components not shown in the pattern inserter.
- **Twig frontend rendering:** `TemplateController` hooks `template_redirect` to intercept frontend requests and render Bootstrap 5 HTML via Twig, bypassing FSE block markup. FSE templates remain for the Site Editor. WordPress functions that produce output (`wp_head`, `wp_footer`, `body_class`, `language_attributes`) are captured via `ob_start()`/`ob_get_clean()` and passed to Twig as safe HTML strings.
- **Navigation menus:** `NavWalker` converts flat `wp_get_nav_menu_items()` into a nested tree for Bootstrap dropdown rendering. Falls back to listing published pages when no menu is assigned.
- **Docker development:** WordPress runs in Docker container `jobroom-wordpress`. The theme directory must be bind-mounted via `compose.override.yaml` (absolute path) for live changes to be visible.
## Session History
### Session 21 — v1.1.2 Security Audit & Performance Fixes (2026-03-01)
**Completed:** Cross-theme security audit with 12 findings, all fixed. Covers WidgetRenderer regex hardening, ContextBuilder performance (O(n) comment tree, sidebar query consolidation, transient caching), and hex-to-RGB code consolidation.
**What was changed:**
- **WidgetRenderer regex fix** (`inc/Block/WidgetRenderer.php`): Combined two `preg_replace` calls into single regex matching `<h2>` with `wp-block-heading` class, preventing mismatched tags.
- **O(n) comment tree** (`inc/Template/ContextBuilder.php`): Parent-indexed lookup map replaces full-scan recursion. Each level now iterates only direct children.
- **Sidebar query consolidation** (`inc/Template/ContextBuilder.php`): Three separate sidebar detection branches merged into single boolean + one `getSidebarData()` call.
- **Transient caching** (`inc/Template/ContextBuilder.php` + `functions.php`): `getSidebarRecentPosts()` and `getSidebarTags()` cached in 1-hour transients with hook-based invalidation (`save_post`, `create/edit/delete_post_tag`).
- **Hex-to-RGB consolidation** (`functions.php`): Eliminated duplicate hex parsing. Added `ctype_xdigit()` validation and type hints to all color utility functions.
**Files modified:**
- `inc/Block/WidgetRenderer.php` — single regex for h2→h4
- `inc/Template/ContextBuilder.php` — O(n) tree, sidebar consolidation, transient caching
- `functions.php` — hex-to-RGB consolidation, type hints, transient invalidation hooks
- `style.css` — version bump to 1.1.2
- `CHANGELOG.md`, `CLAUDE.md` — documentation
### Session 20 — v1.1.1 PHPUnit Test Suite (2026-02-28)
**Completed:** PHPUnit test suite with 64 unit tests and 107 assertions, CI integration, and build pipeline gating.
**What was built:**
- **PHPUnit 11 + Brain\Monkey 2.7**: Added as `require-dev` with PSR-4 `autoload-dev` mapping (`WPBootstrap\Tests\``tests/`).
- **WP_HTML_Tag_Processor stub** (`tests/Stubs/WpHtmlTagProcessor.php`): Functional DOMDocument-based replacement supporting `next_tag()` (by tag name or class_name), `add_class()` (idempotent), and `get_updated_html()`. Uses full HTML document wrapping for reliable body extraction.
- **BlockRendererTest** (28 tests): All 8 render methods — table classes, striped tables, button variants (default/bg/outline/gradient/unknown), button group flex, img-fluid, search input-group, blockquote+cite, pullquote, list-group, empty content returns.
- **WidgetRendererTest** (9 tests): Card structure, title heading, widget ID, type class extraction, generic filtering, h2→h4 regex, multiple h2 elements, empty content.
- **NavWalkerTest** (14 tests): Tree building (empty, single, flat, nested, multi-parent, orphans), node structure, classes, index reset, active detection (current-menu-item, ancestor, is_page, is_category, inactive).
- **TemplateControllerTest** (12 tests): Template resolution via ReflectionMethod for all page types (404, search, post default/full-width, page default/landing/full-width/hero/sidebar, archive, home, fallback).
- **Build pipeline**: `npm run test` and `prebuild` hook gate `npm run build` on passing tests.
- **CI workflow**: New `test` job between `lint` and `build-release` with PHP 8.3 + Composer.
- **Release exclusions**: `tests/`, `phpunit.xml.dist`, `.phpunit.cache/*` excluded from ZIP with verification step.
**Architecture decisions:**
- **Brain\Monkey over full WordPress bootstrap**: WordPress function mocking via `Functions\when()->justReturn()` and `Functions\when()->alias()` enables fast, isolated unit tests without a running WordPress installation.
- **DOMDocument stub over regex**: `WP_HTML_Tag_Processor` stub uses `DOMDocument`/`DOMXPath` for reliable HTML parsing. Full document wrapping (`<!DOCTYPE><html><body>...`) required because `LIBXML_HTML_NOIMPLIED` prevents `<body>` creation in PHP 8.4, breaking `getElementsByTagName('body')`.
- **`is_admin()=true` constructor bypass**: BlockRenderer/WidgetRenderer constructors register WordPress filters. Mocking `is_admin()` to return true causes early exit, enabling direct method testing.
- **ReflectionMethod for private methods**: `TemplateController::resolveTemplate()` is private. Testing via reflection avoids refactoring production code for testability.
- **ContextBuilder and TwigService skipped**: Too many WordPress dependencies for practical unit testing — better suited for integration tests.
**Key findings:**
- `LIBXML_HTML_NOIMPLIED` with `DOMDocument::loadHTML()` on PHP 8.4 does not create a `<body>` element, causing `getElementsByTagName('body')->item(0)` to return null. Solution: wrap input in full HTML document structure.
- Brain\Monkey's `Functions\when()->alias()` supports argument-dependent returns for functions like `is_singular()` that behave differently based on post type argument.
- `spl_object_id()` used in the stub's visited-node tracking enables sequential `next_tag()` advancement matching WordPress's forward-only API.
**Files created:**
- `phpunit.xml.dist` — PHPUnit configuration
- `tests/bootstrap.php` — Autoloader + stub loading
- `tests/Stubs/WpHtmlTagProcessor.php` — Functional DOMDocument-based stub
- `tests/Stubs/WpBlock.php` — Empty class stub
- `tests/Stubs/WpWidget.php` — Empty class stub
- `tests/Unit/Block/BlockRendererTest.php` — 28 tests
- `tests/Unit/Block/WidgetRendererTest.php` — 9 tests
- `tests/Unit/Template/NavWalkerTest.php` — 14 tests
- `tests/Unit/Template/TemplateControllerTest.php` — 12 tests
**Files modified:**
- `composer.json``require-dev`, `autoload-dev`
- `package.json``test`, `prebuild` scripts
- `.gitea/workflows/release.yml` — test job, exclusions, verification
- `.gitignore``.phpunit.cache/`
- `style.css` — version bump to 1.1.1
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
### Session 19 — v1.1.0 Block Renderer, Widget Renderer & Sidebar Post Layout (2026-02-28)
**Completed:** Bootstrap 5 class injection for core blocks, sidebar widget card wrappers, widget SCSS styling, and sidebar-default post template.
**What was built:**
- **BlockRenderer** (`inc/Block/BlockRenderer.php`): Per-block `render_block_{$name}` filters inject Bootstrap classes into 8 core block types using `WP_HTML_Tag_Processor`. Supports button color mapping (WP preset slugs → Bootstrap btn-{variant}), striped tables, responsive images, input-group search forms, blockquote styling, and list-group block style. Extensible via `wp_bootstrap_block_renderer_blocks` filter.
- **WidgetRenderer** (`inc/Block/WidgetRenderer.php`): `dynamic_sidebar_params` filter wraps sidebar widgets in Bootstrap `.card > .card-body` structure. `widget_block_content` filter downgrades block widget `<h2 class="wp-block-heading">` to `<h4>` via preg_replace. Widget ID and type-specific classes extracted from the already-processed `before_widget` string.
- **Widget SCSS** (`src/scss/_widgets.scss`): Comprehensive widget styling — list items with border separators, flush-to-card-edge positioning (negating card-body padding), form-control selects, input-group search forms, pill-badge tag cloud, and secondary-color post dates. Handles both legacy and block widget nesting (`.wp-block-group > ul`).
- **List Group block style** (`functions.php`): `register_block_style('core/list', ...)` for the `is-style-list-group` style option in the editor.
- **Single post sidebar template** (`views/pages/single-sidebar.html.twig`): Two-column Bootstrap layout (8/4 split) with all single post features. "More posts" section uses `row-cols-md-2` for the narrower column.
- **Post template selection** (`inc/Template/TemplateController.php`): Posts default to sidebar layout; `page-full-width` template slug maps to full-width.
- **Sidebar data always loaded for posts** (`inc/Template/ContextBuilder.php`): `getSidebarData()` called unconditionally for `is_singular('post')`.
**Architecture decisions:**
- **Per-block filters over generic `render_block`**: `render_block_{$blockName}` only fires for the targeted block type, avoiding callback overhead on every block render. More maintainable — each handler is scoped to one block type.
- **`WP_HTML_Tag_Processor` over regex**: WordPress 6.7+ class provides safe, forward-only HTML manipulation. `add_class()` is idempotent (no duplicate classes). Handles malformed HTML gracefully.
- **Card-body with card-title, not card-header**: WordPress omits `before_title`/`after_title` entirely when a widget has no title. Using `card-header` for the title would leave an empty `<div class="card-header"></div>` for titleless widgets — broken HTML. Card-body with card-title inside works correctly in both cases.
- **`dynamic_sidebar_params` + regex extraction**: WordPress runs `sprintf` on `before_widget` BEFORE the `dynamic_sidebar_params` filter fires, so placeholder strings (`%1$s`, `%2$s`) are already replaced. Widget ID comes from `$params[0]['widget_id']`, type classes extracted via regex from the processed HTML.
- **Sidebar default for posts**: Blog posts are content-centric and benefit from sidebar context (recent posts, search, tags). Full-width is opt-in via "Full Width" template assignment.
**Key findings:**
- `WP_Block $instance` type hint is too strict for manual `apply_filters()` calls — WordPress passes `null` when filters are invoked outside the block rendering pipeline. Use `?WP_Block $instance = null`.
- Block widgets nest content inside `.wp-block-group` wrappers. CSS selectors like `.card-body > ul` won't match — need `.card-body > .wp-block-group > ul` for flush list positioning.
- `widget_block_content` filter (WordPress 5.8+) fires for block-based widgets only, allowing inner HTML modification without affecting legacy widgets.
- WordPress search block uses `.wp-block-search__inside-wrapper` as the input+button container — adding `.input-group` to this element creates a proper Bootstrap input-group.
- `@extend .btn; @extend .btn-primary` in SCSS works for search submit buttons because Bootstrap is imported before the widgets partial in the SCSS cascade.
**Files created:**
- `inc/Block/BlockRenderer.php` — 8 block handlers
- `inc/Block/WidgetRenderer.php` — card wrapper + heading downgrade
- `src/scss/_widgets.scss` — widget Bootstrap styling
- `views/pages/single-sidebar.html.twig` — two-column post template
**Files modified:**
- `functions.php` — init hooks for both renderers, list-group block style
- `src/scss/style.scss` — widgets import
- `inc/Template/TemplateController.php` — post template selection logic
- `inc/Template/ContextBuilder.php` — always load sidebar for posts
- `style.css` — version bump to 1.1.0
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
### Session 18 — v1.0.12 Admin Bar Offcanvas Fix (2026-02-28)
**Completed:** Scoped admin bar offcanvas padding to mobile viewports only.
**What was fixed:**
- `functions.php`: Added `@media (max-width: 991.98px)` wrapper to the admin bar offcanvas padding CSS so the extra padding does not appear on desktop where the offcanvas renders inline as a regular navbar.
### Session 17 — v1.0.11 Offcanvas Navigation & User Context (2026-02-28)
**Completed:** Switched mobile navigation from Bootstrap collapse to offcanvas, added logged-in user context to the header, and fixed admin bar overlap.
**What was changed:**
- **Offcanvas navigation** (`views/base.html.twig`): Default header include switched from `partials/header.html.twig` (collapse) to `partials/header-offcanvas.html.twig` (offcanvas slide-in from right). The offcanvas variant already existed in the theme.
- **Offcanvas header with user avatar** (`views/partials/header-offcanvas.html.twig`): When logged in, the offcanvas header shows the user's Gravatar avatar and display name linking to the WooCommerce My Account page. Falls back to the site name when logged out.
- **Dark mode toggle repositioned**: Moved from the offcanvas body to the offcanvas footer (`d-lg-none`) on mobile. On desktop (≥lg), the toggle remains visible next to the navbar via a separate `d-none d-lg-block` wrapper.
- **User context in ContextBuilder** (`inc/Template/ContextBuilder.php`): New `getUserData()` method providing `user.logged_in`, `user.display_name`, `user.avatar` (rendered `<img>` with `rounded-circle` class), and `user.account_url` (WooCommerce My Account or WP admin profile fallback).
- **Admin bar offcanvas overlap fix** (`functions.php`): Inline CSS injected via `wp_add_inline_style()` when `is_admin_bar_showing()` is true. Adds `padding-top: var(--wp-admin--admin-bar--height, 32px)` to `.offcanvas` so the offcanvas content clears the admin bar.
**Files modified:**
- `views/base.html.twig` — header include changed to offcanvas variant
- `views/partials/header-offcanvas.html.twig` — user avatar header, dark mode toggle in footer
- `inc/Template/ContextBuilder.php``getUserData()` method, `user` key in context
- `functions.php` — admin bar offcanvas padding inline style
- `style.css` — version bump to 1.0.11
- `CHANGELOG.md` — v1.0.11 entry
**Key learnings:**
- Bootstrap offcanvas inside `navbar-expand-lg` uses `position: fixed; top: 0` which is covered by the WordPress admin bar (`z-index: 99999`). Since the offcanvas z-index (1045) is lower, adjusting `top` alone doesn't help visually — `padding-top` on the offcanvas content is the practical fix.
- `wp_add_inline_style()` bypasses file-level browser caching, making it more reliable for conditional CSS rules than editing the main stylesheet.
- WordPress's `--wp-admin--admin-bar--height` CSS custom property (set on `:root`) adjusts between 32px (desktop) and 46px (mobile ≤782px), making it the ideal value for admin bar offset calculations.
- `get_avatar()` accepts an `$args` array where CSS classes can be passed via the `class` key, avoiding post-processing of the HTML output.
### Session 16 — v1.0.10 Title Double-Encoding Fix (2026-02-25)
**Completed:** Fixed double-encoding of HTML entities in page titles rendered through Twig.
**Root cause:** WordPress's `get_the_title()` returns titles with HTML entities pre-encoded (e.g. `&``&#038;`). `ContextBuilder` passed these pre-encoded strings to Twig as template variables. Twig's autoescape then re-encoded the `&` in `&#038;` to `&amp;#038;`, which browsers rendered as the literal text `&#038;` instead of `&`. Affected all pages with `&` in their title (e.g. help pages "Bewerbungen & Nachrichten", "Konto & Sicherheit", "Abonnements & Abrechnung").
**Fix:** Wrapped all 6 `get_the_title()` calls in `ContextBuilder.php` with `wp_specialchars_decode()`. This decodes WordPress entities back to raw characters before Twig, allowing Twig autoescape to properly encode them once. XSS-safe because Twig still escapes all output.
**Files modified:**
- `inc/Template/ContextBuilder.php``wp_specialchars_decode()` on all 6 `get_the_title()` calls
- `style.css` — version bump to 1.0.10
- `CHANGELOG.md` — v1.0.10 entry
### Session 15 — v1.0.9 Performance Optimization (2026-02-19)
**Completed:** Two targeted performance fixes for production environments.
**Changes made:**
- **Color variation CSS transient caching** (`functions.php`): `wp_bootstrap_variation_colors()` now caches the generated Bootstrap CSS variable overrides in a 24-hour transient keyed by `wp_bootstrap_variation_css_` + `md5(get_stylesheet())`. Previously the palette loop and CSS string building executed on every frontend request. Transient is invalidated on `switch_theme` and `save_post_wp_global_styles` hooks so Design Editor changes apply immediately.
- **Twig `auto_reload` gated behind `WP_DEBUG`** (`inc/Twig/TwigService.php`): Hardcoded `auto_reload => true` caused Twig to `stat()` each compiled template file on every request to detect source file changes. Changed to `auto_reload => WP_DEBUG` so stat checks only occur during development. In production, compiled templates are served from cache unconditionally.
**Files modified:**
- `functions.php` — transient caching and invalidation for variation CSS
- `inc/Twig/TwigService.php``auto_reload => WP_DEBUG`
- `style.css` — version bump to 1.0.9
- `CHANGELOG.md` — v1.0.9 entry
### Session 14 — v1.0.8 Security Audit & Hardening (2026-02-19)
**Completed:** Comprehensive OWASP-aligned security audit. Two parallel background agents reviewed all PHP (functions.php, ContextBuilder, NavWalker, TemplateController, TwigService, all patterns) and JavaScript/Twig templates. Four targeted security fixes applied.
**Findings and fixes:**
- **Archive term description XSS (High)**: `get_the_archive_description()` returns raw term content editable by Editor-role users. Templates rendered it with `|raw`, creating a stored XSS path. Fixed: wrapped with `wp_kses_post()` in `ContextBuilder::getArchiveData()`. Same applied to `get_the_archive_title()`.
- **Comment author injection (Low, defense-in-depth)**: `comment_author` and `comment_author_url` were passed to Twig as raw database values. Fixed: `esc_html()` applied to author name, `esc_url()` applied to author URL in `ContextBuilder::buildCommentTree()`. Template updated to output pre-escaped URL via `|raw` rather than calling `esc_url()` in Twig.
- **Dark mode localStorage whitelist (Medium)**: `getPreferredTheme()` returned any stored value without validation, allowing attribute injection if a malicious script wrote to `localStorage`. Fixed: strict equality check against `['dark', 'light']` before trusting the stored value.
- **Twig escaping functions not marked safe (Medium)**: `esc_html()`, `esc_attr()`, `esc_url()` registered in `TwigService` lacked `['is_safe' => ['html']]`, meaning any future autoescape enablement would cause double-encoding. Fixed: all three now carry the `is_safe` declaration.
**Confirmed secure (no action needed):**
- All `|raw` filter usages for widget HTML, comment content, comment reply links, and comment forms are by-design (WordPress core output)
- Pattern files: no direct-access guards needed (loaded only via `register_block_pattern()`)
- No SQL injection vectors (`$wpdb` not used directly; all data via WordPress functions)
- `TemplateController` error handling: `\Throwable` caught, logged, and gated behind `WP_DEBUG`
- `do_shortcode()` and `wp_kses_post()` Twig functions correctly marked `is_safe`
- `wp_head()`, `wp_footer()`, `body_class()` Twig functions correctly use output buffering + `is_safe`
**Key learnings:**
- WordPress Twig themes should not enable `autoescape => 'html'` globally: `get_the_title()` applies `wptexturize()` which returns HTML entities (`&mdash;`, `&ldquo;`). Autoescape would double-encode these, corrupting post title rendering.
- `esc_url()` does more than HTML-encoding — it validates the URL scheme and strips dangerous protocols (`javascript:`, `data:`). Always use it for user-supplied URLs, even when autoescape is active.
- Registering WordPress escaping functions (`esc_url`, `esc_html`, `esc_attr`) as Twig functions without `is_safe => html` silently creates a double-encoding trap: calling `{{ esc_url(url) }}` with autoescape on would produce `&amp;amp;` instead of `&amp;`.
- Added `.markdownlint.json` disabling MD024 (duplicate headings, expected in changelogs) and MD013 (line length).
**Files modified:**
- `inc/Template/ContextBuilder.php` — archive data sanitization, comment field escaping
- `inc/Twig/TwigService.php``is_safe => html` on three escaping functions
- `views/partials/comment-item.html.twig` — use pre-escaped author URL
- `src/js/dark-mode.js` — localStorage whitelist
- `assets/js/dark-mode.js` — rebuilt compiled output
- `style.css` — version bump to 1.0.8
- `CHANGELOG.md` — v1.0.8 entry
- `.markdownlint.json` — created
### Session 13 — v1.0.5 Translation Files (2026-02-11)
**Completed:** Standardized translation file naming and added 11 new locale translations.
**What was done:**
- Renamed all .po files to use `wp-bootstrap-` prefix for WordPress text domain convention
- Previously: mixed naming (some with prefix like `wp-bootstrap-en_GB.po`, some without like `de_CH.po`)
- Now: all 13 files follow `wp-bootstrap-{locale}.po` pattern
- Compiled all 13 .po files to .mo for local development
- Added 11 new locales: de_CH_informal, de_DE, de_DE_informal, en_GB, es_ES, fr_CH, it_CH, it_IT, nl_NL, pl_PL, pt_PT
**Files renamed:**
- `de_CH.po``wp-bootstrap-de_CH.po`
- `de_CH_informal.po``wp-bootstrap-de_CH_informal.po`
- `de_DE.po``wp-bootstrap-de_DE.po`
- `de_DE_informal.po``wp-bootstrap-de_DE_informal.po`
- `es_ES.po``wp-bootstrap-es_ES.po`
- `fr_CH.po``wp-bootstrap-fr_CH.po`
- `fr_FR.po``wp-bootstrap-fr_FR.po`
- `it_CH.po``wp-bootstrap-it_CH.po`
- `it_IT.po``wp-bootstrap-it_IT.po`
- `pt_PT.po``wp-bootstrap-pt_PT.po`
**Key learnings:**
- WordPress expects translation files named `{text-domain}-{locale}.po` (e.g., `wp-bootstrap-de_CH.po`)
- `load_theme_textdomain()` loads files matching this pattern from the `languages/` directory
- Files without the text domain prefix would not be loaded by WordPress
### Session 12 — v1.0.4 Template Render Filter (2026-02-11)
**Completed:** Added `wp_bootstrap_should_render_template` filter to `TemplateController::render()` for clean plugin/theme separation.
**What was added:**
- New `wp_bootstrap_should_render_template` filter at the top of `TemplateController::render()` — returns `true` by default, but plugins can return `false` to prevent the theme from rendering a request
- Enables the wp-jobroom plugin to handle its own custom post types and routes without the theme's `TemplateController` racing to render first
- Theme remains 100% standalone — the filter is a no-op when no plugin hooks into it
**Key learnings:**
- WordPress `template_redirect` hook priority ordering is the primary mechanism for plugin/theme rendering coordination: plugin Router at priority 5, theme TemplateController at default priority 10
- Adding a simple filter check (`apply_filters('wp_bootstrap_should_render_template', true)`) is the cleanest decoupling mechanism — no cross-project class detection needed
### Session 11 — v1.0.3 Conditional Page Title (2026-02-11)
**Completed:** Made `<h1>` on page template conditional to prevent double headings when plugins provide their own titles.
**What was fixed:**
- `views/pages/page.html.twig` now wraps `<h1>{{ post.title }}</h1>` in `{% if post.title is not empty %}` guard
- When a plugin passes empty `post.title` via `render_via_theme_twig()`, the theme's `<h1>` is skipped
- Prevents duplicate headings on pages where plugin templates render their own `<h1>` with richer context (icons, badges, meta)
**Key learnings:**
- Plugins that delegate rendering to the parent theme via `TwigService` should be able to opt out of the theme's `<h1>` by passing empty `post.title`
- The `is not empty` Twig test correctly handles both `null` and empty string `''`
### Session 10 — v1.0.2 Title Tag Fix (2026-02-10)
**Completed:** Fixed missing HTML `<title>` tag on all pages rendered by the theme's Twig pipeline.
**What was fixed:**
- Added `add_theme_support('title-tag')` to `wp_bootstrap_setup()` in `functions.php`
**Root cause:**
- The theme's `base.html.twig` calls `{{ wp_head() }}` which fires the `wp_head` action
- WordPress hooks `_wp_render_title_tag()` to `wp_head` at priority 1, which outputs the `<title>` tag
- However, this hook only fires when the theme declares `add_theme_support('title-tag')`
- The theme never made this declaration, so `wp_head()` output included styles and scripts but no `<title>` element
- All pages rendered by `TemplateController` (via `base.html.twig`) were affected
**Key learnings:**
- `add_theme_support('title-tag')` is required even for themes that render `wp_head()` via Twig — WordPress does not output `<title>` without it
- The absence of a `<title>` tag is invisible in the rendered page but affects SEO, browser tab display, and bookmarking
- This support declaration has been standard since WordPress 4.1 and should always be included in `after_setup_theme`
### Session 9 — v1.0.1 Bootstrap Icons (2026-02-09)
**Completed:** Bootstrap Icons web font integration via SCSS build pipeline.
**What was built:**
- Added `bootstrap-icons` npm dependency (v1.13.1)
- Imported Bootstrap Icons SCSS in both `style.scss` and `editor-style.scss`
- Added `$bootstrap-icons-font-src` variable override in `_variables.scss` to point `@font-face` at `assets/fonts/`
- Added `copy:icons` npm script to copy `.woff`/`.woff2` font files from `node_modules` to `assets/fonts/`
- Updated `build` script to include `copy:icons` step
**Key learnings:**
- Bootstrap Icons SCSS uses `$bootstrap-icons-font-src` to allow overriding the `@font-face` `src` declaration — set it before the import to control font file paths
- The existing `--load-path=node_modules` Sass flag resolves `@import "bootstrap-icons/font/bootstrap-icons"` without any extra configuration
- Font files (`.woff2` at 131KB, `.woff` at 176KB) are small enough to serve as web fonts without performance concern
### Session 8 — v1.0.0 Release (2026-02-08)
**Completed:** Sidebar widget area registration, Twig widget rendering with fallback, documentation refresh, v1.0.0 release.
**What was built:**
- `register_sidebar()` for `primary-sidebar` widget area with Bootstrap-styled wrapper markup
- Widget area rendering in `ContextBuilder::getSidebarData()` via `ob_start()` + `dynamic_sidebar()` with fallback to built-in content
- Twig sidebar template conditional: renders WordPress widgets when assigned, falls back to recent posts/search/tags otherwise
- Updated README.md with accurate feature counts (15 variations, 41 patterns, 3 translations, accessibility, RTL, widget area)
- Updated all translation files (.pot, de_CH.po, fr_FR.po) with widget area strings
**Key learnings:**
- `is_active_sidebar()` returns true only when widgets are assigned to the area, making it the right condition for fallback logic
- `dynamic_sidebar()` outputs widget HTML directly, so `ob_start()`/`ob_get_clean()` is needed to capture it for Twig
- Widget area `before_widget`/`after_widget` markup should use Bootstrap utility classes (`widget mb-4`) for consistent spacing
- Widget title markup (`before_title`/`after_title`) should match existing sidebar heading styles (`sidebar-heading h6 text-uppercase fw-semibold`)
### Session 7 — v0.3.2/v0.3.3 Dark Mode & Style Variation Bridge (2026-02-08)
**Completed:** Fixed dark mode rendering conflicts between WordPress global styles and Bootstrap, fixed form element styling in dark mode, bridged style variation colors to Bootstrap CSS custom properties, fixed variation detection to read from correct palette origin.
**What was built:**
- Style variation bridge function (`wp_bootstrap_variation_colors`) that maps WordPress palette colors to Bootstrap CSS custom properties via `wp_enqueue_scripts`
- Helper functions for color manipulation: `wp_bootstrap_hex_to_rgb()`, `wp_bootstrap_build_surface_css()`, `wp_bootstrap_mix_hex()`, `wp_bootstrap_hex_to_rgb_array()`, `wp_bootstrap_relative_luminance()`
- Variation detection comparing `theme` origin palette against hardcoded base defaults (base, contrast, primary)
- Dark mode body override in `_custom.scss` using `!important` to defeat WordPress global styles specificity
- Broad dark mode rules for all native form elements (`select`, `input`, `textarea`) to catch plugin-generated controls
- Fixed `footer-columns.html.twig` to use semantic `bg-body-tertiary` instead of hardcoded `bg-dark text-light`
**Key learnings:**
- WordPress puts style variation colors in the `theme` palette origin, NOT `custom` -- `wp_get_global_settings(['color', 'palette', 'theme'])` returns the base theme.json merged with the active variation
- The `custom` palette origin contains user manual edits from the Site Editor, but its data structure may lack expected `slug`/`color` keys
- To detect an active variation, compare `theme` origin colors against known base theme.json defaults rather than checking for slugs in `custom`
- WordPress `theme.json` `styles.color` generates `body { background-color: var(--wp--preset--color--base) }` directly on `body`, which overrides inherited CSS variables from `html[data-bs-theme="dark"]` -- removing `styles.color` from theme.json is the cleanest fix
- CSS variables defined directly on `body` beat inherited values from `html` due to specificity, requiring `!important` on `html[data-bs-theme="dark"] body` to ensure Bootstrap dark mode works
- Plugin-generated form elements (e.g., `<select class="jr-search-form__filter-select">`) lack Bootstrap classes and need explicit dark mode styling via element selectors
### Session 6 — v0.3.1 Style Variations (2026-02-08)
**Completed:** Added 8 new style variations (4 light, 4 dark) to the Design Editor.
**What was built:**
- 4 new light palettes: Rose (pink/fuchsia), Sand (warm amber/beige), Lavender (soft purple), Mint (fresh teal)
- 4 new dark palettes: Slate (neutral blue-gray), Mocha (coffee/warm brown), Nebula (space teal-cyan), Obsidian (near-black with red)
- All variations follow the established 10-slug color pattern for cross-variation pattern compatibility
**Key learnings:**
- Dark style variations swap base/contrast (dark base, light contrast) and use darker shades for the `light` and `dark` slugs to maintain proper surface hierarchy
- Button and link hover states in dark palettes need explicit `color` and `text` overrides since the default theme.json assumes light base
### Session 5 — v0.3.0 Polish (2026-02-08)
**Completed:** Accessibility audit and fixes, security hardening, performance optimization, RTL language support, French translation, inline style cleanup.
**What was built:**
- Skip-to-content link in `base.html.twig` with visually-hidden CSS class (visible on focus)
- ARIA labels on all `<nav>` elements across 4 header variants, 2 footer variants, and sidebar
- `aria-current="page"` on active dropdown items in all header variants
- `loading="lazy"` on post thumbnails, card images, and comment avatars
- Screen reader announcement (`aria-live="polite"`) in `dark-mode.js` for theme toggle
- Font preload `<link>` tags for Inter and Lora `.woff2` files via `wp_head` priority 1
- RTL stylesheet (`src/scss/rtl.scss`) conditionally loaded when `is_rtl()` is true
- Logical CSS properties (`border-inline-start`, `padding-inline-start`) replacing physical directions in block styles
- French translation (`fr_FR.po`) covering all ~216 translatable strings
- CSS classes replacing inline styles: `.post-thumbnail`, `.card-thumbnail`, `.sidebar-heading`, `.hero-overlay`
- XSS fix in search template: `search_query|e('html')` before `|raw` output
- Explicit `esc_url()` on comment author URLs in Twig
- Updated `.pot` and `de_CH.po` with 4 new accessibility strings
- RTL build step added to npm scripts
**Key learnings:**
- WordPress `is_rtl()` detects RTL locales, allowing conditional stylesheet loading without doubling CSS bundle size
- CSS logical properties (`border-inline-start`, `padding-inline-start`) are the modern approach to RTL support, replacing physical left/right properties
- Twig `|e('html')` filter must be applied *before* concatenation with `|raw` HTML to prevent XSS -- `search_query|e('html')` inside a `|format()` call
- Font preloading via `wp_head` at priority 1 ensures preload hints appear before render-blocking stylesheets
- `aria-live="polite"` with `role="status"` announces dynamic changes to screen readers without interrupting current reading flow
### Session 4 — v0.2.0 Design Editor (2026-02-08)
**Completed:** Full Design Editor compatibility, custom block categories, page templates, header/footer/navigation variations.
**What was built:**
- Enhanced editor stylesheet importing full Bootstrap SCSS for WYSIWYG fidelity
- Editor overrides SCSS (`_editor-overrides.scss`) for alignment and spacing
- Bootstrap JS loaded in block editor via `enqueue_block_editor_assets`
- 3 custom block categories (`block_categories_all` filter): Bootstrap Layout, Components, Navigation
- 3 custom pattern categories: Layout, Components, Navigation
- 6 layout/component patterns: container, 2-col, 3-col, full-width section, card group, accordion
- 3 full-page patterns: about, services, contact
- 4 custom page templates (FSE + Twig): landing, full-width, hero, sidebar
- 2 header variations (FSE parts + patterns + Twig): centered, transparent
- 2 footer variations (FSE parts + patterns + Twig): minimal, multi-column
- 2 navigation patterns: dark navbar, offcanvas
- Offcanvas navigation Twig partial with Bootstrap offcanvas component
- Twig block inheritance in `base.html.twig` for header/footer variant overrides
- Header/footer variant support via `get_theme_mod()` in `ContextBuilder`
- Custom page template routing via `get_page_template_slug()` in `TemplateController`
- Shadow presets, aspect ratios, custom layout values in `theme.json`
- Transparent header and offcanvas dark mode SCSS styles
- Updated translations (`.pot` and `de_CH.po`) with ~70 new translatable strings
**Key learnings:**
- WordPress `block_categories_all` filter (block inserter categories) and `register_block_pattern_category()` (pattern inserter categories) are separate APIs serving different parts of the editor UI
- FSE template parts (`parts/`) use pattern references (`<!-- wp:pattern {"slug":"..."} /-->`) to keep markup in PHP pattern files for i18n support
- Twig blocks (`{% block header %}`) enable page-level templates to override header/footer without modifying `base.html.twig`
- `get_page_template_slug()` returns the custom template slug assigned in the page editor, used in `TemplateController` for routing to the correct Twig template
- Bootstrap SCSS deprecation warnings (Dart Sass 3.0 migration) are upstream issues, not blocking — the build succeeds
### Session 3 — v0.1.1 Bootstrap Frontend Rendering (2026-02-08)
**Completed:** Full Twig-based Bootstrap 5 frontend rendering, replacing FSE block markup on the public-facing site.
**What was built:**
- `TemplateController` class hooking `template_redirect` to render Twig templates for all page types
- `ContextBuilder` class gathering WordPress data (posts, menus, pagination, comments, sidebar, archive info) into structured arrays
- `NavWalker` class converting flat menu items to nested tree for Bootstrap dropdown menus
- 20 Twig templates: base layout, 5 page templates (index, single, page, archive, search, 404), 9 partials (header, footer, pagination, sidebar, comments, search form, dark mode toggle, meta, post navigation), 3 components (post card, grid card, post loop)
- Enhanced `TwigService` with WordPress output-buffering functions, globals, and filters
- Navigation menu locations (primary, footer) with pages fallback
- Comment form Bootstrap styling filter
- README.md project documentation
**Key learnings:**
- `template_redirect` + `exit()` cleanly bypasses FSE rendering on frontend while preserving Site Editor functionality
- WordPress functions that produce output (`wp_head`, `wp_footer`, `body_class`, `language_attributes`) must be captured via `ob_start()`/`ob_get_clean()` for use in Twig, and marked with `is_safe => html`
- With `optimize-autoloader: true` in `composer.json`, new PSR-4 classes require `composer dump-autoload` to regenerate the static classmap
- Docker bind mounts require absolute paths in `compose.override.yaml` -- relative paths create empty directories
- Post content is rendered via `apply_filters('the_content', get_the_content())` which processes Gutenberg blocks into standard HTML that Bootstrap CSS handles natively
### Session 2 — v0.1.0 Core Theme (2026-02-08)
**Completed:** Full v0.1.0 milestone implementation.
**What was built:**
- 16 block patterns across 7 new categories (hero, features, CTA, testimonials, pricing, contact, text)
- Dark mode toggle with SVG sun/moon icons, localStorage persistence, `prefers-color-scheme` detection
- 17 custom block styles mapping Bootstrap components to WordPress blocks
- 4 style variations: Ocean, Forest, Sunset, Midnight
- Sidebar template part + "Blog with Sidebar" custom template
- Inter + Lora variable web fonts with Display font size
**Key learnings:**
- CI uses `npm install` / `npm run build``yarn` is not available in the CI runner
- Bootstrap 5 alert colors are hardcoded (not CSS variables), so explicit dark mode overrides are needed in SCSS
- Anti-flash for dark mode requires a synchronous inline script via `wp_add_inline_script('handle', '...', 'before')` — the deferred JS alone causes a flash
- Variable fonts use `fontWeight: "100 900"` range syntax in `theme.json` `fontFace` declarations
- Style variation JSON files must maintain identical color slug names across all variations for patterns to work correctly
- When re-tagging a release, delete the remote tag first (`git push origin :refs/tags/vX.X.X`) then recreate and push
### Session 1 — v0.0.1 Getting Started (2026-02-08)
**Completed:** Initial theme scaffolding, Bootstrap 5 integration, SASS pipeline, Twig setup, CI/CD workflow, basic FSE templates and patterns, i18n support.

52
PLAN.md
View File

@@ -10,10 +10,12 @@ WP Bootstrap is a modern WordPress Block Theme built from scratch with Bootstrap
Full Site Editing (FSE) Block Theme following the WordPress 6.x template hierarchy:
- **Templates** (`templates/`): HTML files with WordPress block markup
- **Template Parts** (`parts/`): Reusable header/footer components
- **Templates** (`templates/`): HTML files with WordPress block markup (Site Editor)
- **Template Parts** (`parts/`): Reusable header/footer components (Site Editor)
- **Patterns** (`patterns/`): PHP files with block markup and i18n support
- **Design Tokens** (`theme.json`): Colors, typography, spacing mapped to Bootstrap 5
- **Twig Templates** (`views/`): Bootstrap 5 HTML rendered on the frontend via `template_redirect`
- **Template Engine** (`inc/Template/`): Controller, context builder, and nav walker for Twig rendering
### Technology Stack
@@ -52,28 +54,40 @@ node_modules/bootstrap/dist/js/ → copyfiles → assets/js/bootstrap.bundle.min
- [x] Sidebar template part
- [x] Enhanced typography settings
### v0.2.0 - Design Editor
### v0.1.1 - Bootstrap Frontend Rendering (Complete)
- [ ] Full Design Editor compatibility
- [ ] Custom block categories
- [ ] Page-level patterns and templates
- [ ] Enhanced header/footer variations
- [ ] Custom navigation styles
- [x] Twig-based frontend rendering via `template_redirect` hook
- [x] `TemplateController`, `ContextBuilder`, `NavWalker` PHP classes
- [x] 20 Twig templates (base layout, pages, partials, components)
- [x] Bootstrap 5 navbar, cards, pagination, comments, sidebar
- [x] Enhanced `TwigService` with WordPress functions and globals
- [x] Navigation menu locations (primary, footer)
- [x] Comment form Bootstrap styling
- [x] README.md project documentation
### v0.3.0 - Polish
### v0.2.0 - Design Editor (Complete)
- [ ] Performance optimization (selective Bootstrap imports)
- [ ] Accessibility audit and fixes
- [ ] RTL language support
- [ ] Additional translations
- [ ] Documentation
- [x] Full Design Editor compatibility
- [x] Custom block categories
- [x] Page-level patterns and templates
- [x] Enhanced header/footer variations
- [x] Custom navigation styles
### v1.0.0 - Release
### v0.3.0 - Polish (Complete)
- [ ] All features complete and tested
- [ ] WordPress.org theme review compliance
- [ ] Comprehensive documentation
- [ ] Full test coverage
- [x] Performance optimization
- [x] Accessibility audit and fixes
- [x] Security audit and fixes
- [x] RTL language support
- [x] Additional translations
- [x] Documentation
### v1.0.0 - Release (Complete)
- [x] All features complete and tested
- [x] Widget area registration and Twig rendering with fallback
- [x] Comprehensive documentation (README refresh, updated translations)
- [x] Code quality audit passed (no TODO/FIXME, proper escaping, no security issues)
## Bootstrap 5 Integration Strategy

171
README.md
View File

@@ -1,21 +1,33 @@
# WP Bootstrap
A modern WordPress Block Theme built from scratch with Bootstrap 5.
A modern WordPress Block Theme built from scratch with Bootstrap 5. Features responsive design, dark mode support, Twig template rendering, and full compatibility with the WordPress Site Editor.
## Features
- Full Site Editing (FSE) support
- Bootstrap 5 CSS and JavaScript
- Responsive design
- Dark mode ready (Bootstrap 5.3 dark mode variables)
- Twig 3.0 template engine
- WordPress i18n support
- Gitea CI/CD automated releases
- **Bootstrap 5 Frontend** -- Proper Bootstrap 5 HTML (navbar, cards, pagination, grid) rendered via Twig templates
- **Dark Mode** -- Toggle with localStorage persistence and `prefers-color-scheme` support
- **Full Site Editing** -- Compatible with the WordPress Site Editor for admin editing
- **Style Variations** -- 15 color schemes (7 light, 7 dark, plus default) with live Design Editor customization
- **Block Patterns** -- 41 patterns across 11 categories (hero, features, CTA, testimonials, pricing, contact, text, layout, components, navigation, pages)
- **Bootstrap Icons** -- 2,000+ icons available via CSS classes (`bi bi-*`)
- **Block Renderer** -- Automatic Bootstrap 5 class injection on 8 core block types (table, button, image, search, quote, pullquote, list) via `render_block` filters
- **Widget Renderer** -- Sidebar widgets wrapped in Bootstrap cards with proper heading hierarchy
- **Block Styles** -- 18 custom styles mapping Bootstrap components to WordPress blocks (including List Group)
- **Custom Templates** -- Landing (no header/footer), full-width, hero, sidebar page templates; blog posts default to sidebar layout
- **Header/Footer Variations** -- Default, centered, transparent headers; default, minimal, multi-column footers
- **Navigation Styles** -- Dark navbar, offcanvas mobile navigation
- **Widget Area** -- Sidebar widget area manageable via WordPress admin, with built-in fallback
- **Accessibility** -- Skip-to-content link, ARIA labels, `aria-current` on active items, screen reader announcements
- **RTL Support** -- Right-to-left language support with logical CSS properties
- **Translation Ready** -- Full i18n support with 14 locales (en_US, de_CH, de_CH_informal, de_DE, de_DE_informal, en_GB, es_ES, fr_CH, fr_FR, it_CH, it_IT, nl_NL, pl_PL, pt_PT)
- **Responsive** -- Mobile-first design with Bootstrap's responsive grid
## Requirements
- WordPress 6.7 or higher
- PHP 8.3 or higher
- Composer
- Node.js 20+
## Installation
@@ -31,16 +43,18 @@ A modern WordPress Block Theme built from scratch with Bootstrap 5.
git clone ssh://git@src.bundespruefstelle.ch:2022/magdev/wp-bootstrap.git
cd wp-bootstrap
composer install
yarn install
yarn build
npm install
npm run build
```
Activate the theme in **Appearance > Themes** in the WordPress admin.
## Development
### Prerequisites
- Node.js 20+
- Yarn
- npm
- Composer
- PHP 8.3+
@@ -48,33 +62,132 @@ yarn build
| Command | Description |
| --- | --- |
| `yarn build` | Compile SCSS, minify CSS, copy Bootstrap JS |
| `yarn dev` | Watch SCSS files and recompile on changes |
| `yarn scss` | Compile SCSS only |
| `yarn postcss` | Minify CSS with Autoprefixer |
| `composer install` | Install PHP dependencies |
| `npm run build` | Full production build (runs tests, copies JS, compiles SCSS, minifies CSS) |
| `npm run test` | Run PHPUnit test suite |
| `npm run dev` | Watch SCSS files and recompile on changes |
| `npm run scss` | Compile SCSS only |
| `npm run postcss` | Minify CSS with Autoprefixer and cssnano |
| `composer install` | Install PHP dependencies (Twig) |
### Testing
The theme includes a PHPUnit test suite with 64 unit tests and 107 assertions covering the core PHP classes:
- **BlockRenderer** -- All 8 render methods (table, button, buttons, image, search, quote, pullquote, list)
- **WidgetRenderer** -- Card wrapping, heading downgrade, class extraction
- **NavWalker** -- Tree building, active item detection, orphan handling
- **TemplateController** -- Template resolution for all page types
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) for WordPress function mocking and a functional `WP_HTML_Tag_Processor` stub.
```bash
# Run tests
composer exec -- phpunit
# Tests also run automatically before every build
npm run build
```
### Build Pipeline
1. `test` -- Run PHPUnit test suite (automatic via `prebuild` hook)
2. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
3. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
4. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
5. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
6. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
7. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
## Architecture
### Frontend Rendering
The theme uses a dual-rendering approach:
- **Site Editor (admin):** FSE block templates in `templates/` and `parts/` for visual editing
- **Frontend (public):** Twig templates in `views/` render Bootstrap 5 HTML via the `template_redirect` hook
The `TemplateController` intercepts frontend requests and renders the appropriate Twig template with data gathered by `ContextBuilder`. Plugins can hook into the `wp_bootstrap_should_render_template` filter to prevent rendering for specific requests (e.g., when a plugin handles its own custom post types). FSE templates remain untouched for the WordPress admin editor.
### Style Variation Bridge
WordPress style variation colors are bridged to Bootstrap CSS custom properties at runtime. When a variation is selected in the Design Editor, the theme reads the active palette via `wp_get_global_settings()` and outputs inline CSS that overrides Bootstrap's compiled defaults. This works for both light and dark mode.
### Key PHP Classes
| Class | Purpose |
| --- | --- |
| `TwigService` | Singleton Twig environment with WordPress functions and globals |
| `TemplateController` | Hooks `template_redirect`, resolves and renders Twig templates |
| `ContextBuilder` | Gathers WordPress data (posts, menus, pagination, comments, sidebar) |
| `NavWalker` | Converts flat menu items to nested tree for Bootstrap dropdowns |
| `BlockRenderer` | Injects Bootstrap 5 classes into core block HTML via `render_block` filters |
| `WidgetRenderer` | Wraps sidebar widgets in Bootstrap card components |
### Navigation Menus
Register menus in **Appearance > Menus**:
- **Primary Navigation** -- Displayed in the Bootstrap navbar with dropdown support
- **Footer Navigation** -- Displayed in the footer
If no menu is assigned, the primary location falls back to listing published pages.
### Widget Areas
The theme registers a **Sidebar** widget area. When widgets are assigned via **Appearance > Widgets**, they replace the default sidebar content. When no widgets are assigned, the sidebar displays recent posts, a search form, and a tag cloud. All sidebar widgets are automatically wrapped in Bootstrap card components with consistent heading styles.
### Block Renderer
The `BlockRenderer` class hooks per-block `render_block_{$name}` filters (more performant than a single `render_block` filter) and uses WordPress's `WP_HTML_Tag_Processor` for safe, idempotent class injection. Only active on the frontend — admin, REST API, and AJAX requests are skipped. Child themes can modify the block-to-handler map via the `wp_bootstrap_block_renderer_blocks` filter.
### Widget Renderer
The `WidgetRenderer` class transforms sidebar widget output into Bootstrap card components. It hooks `dynamic_sidebar_params` for wrapper HTML and `widget_block_content` for inner content adjustments (heading level downgrade from `<h2>` to `<h4>`). The card structure uses `card-body` with `card-title` inside, ensuring valid HTML whether or not a widget outputs a title.
### Project Structure
```txt
wp-bootstrap/
├── assets/ Compiled CSS, JS, and images
├── inc/ PHP classes (PSR-4 autoloaded)
├── languages/ Translation files (.pot, .po)
├── parts/ FSE template parts (header, footer)
├── patterns/ Block patterns
├── src/scss/ SCSS source files
├── templates/ FSE page templates
├── views/ Twig templates
├── functions.php Theme bootstrap
├── style.css Theme metadata
└── theme.json Design tokens and settings
+-- assets/ Compiled CSS, JS, fonts
+-- inc/
| +-- Block/ BlockRenderer, WidgetRenderer
| +-- Template/ TemplateController, ContextBuilder, NavWalker
| +-- Twig/ TwigService singleton
+-- languages/ Translation files (.pot, .po)
+-- patterns/ Block patterns (PHP)
+-- parts/ FSE template parts (header, footer, sidebar, variants)
+-- src/
| +-- js/ Source JavaScript
| +-- scss/ Source SCSS
+-- styles/ Style variations (JSON)
+-- tests/
| +-- Stubs/ WordPress class stubs for testing
| +-- Unit/ PHPUnit test cases
+-- templates/ FSE templates (HTML)
+-- views/ Twig templates (Bootstrap 5 HTML)
| +-- base.html.twig
| +-- pages/ Page templates (index, single, page, archive, search, 404)
| +-- partials/ Reusable parts (header, footer, pagination, sidebar, etc.)
| +-- components/ UI components (post card, post loop)
+-- functions.php Theme bootstrap
+-- style.css Theme metadata
+-- theme.json Design tokens and settings
```
## Technology Stack
- **PHP 8.3+** with PSR-4 autoloading
- **Twig 3.0** via Composer
- **Bootstrap 5.3+** CSS & JS (served locally)
- **Dart Sass** for SCSS compilation
- **PostCSS** with Autoprefixer and cssnano
- **PHPUnit 11** with Brain\Monkey for WordPress function mocking
## License
GPL-2.0-or-later
GPL-2.0-or-later. See <http://www.gnu.org/licenses/gpl-2.0.html>.
## Author
Marco Graetsch - [src.bundespruefstelle.ch/magdev](https://src.bundespruefstelle.ch/magdev)
Marco Graetsch - <https://src.bundespruefstelle.ch/magdev>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

4
assets/css/rtl.css Normal file
View File

@@ -0,0 +1,4 @@
/*!
* WP Bootstrap Theme - RTL Overrides
* Right-to-left language support
*/.is-style-blockquote-accent{border-left:none;border-right:4px solid var(--wp--preset--color--primary);border-radius:.375rem 0 0 .375rem}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -20,7 +20,9 @@
*/
function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
// Whitelist: only honour known-good values to prevent attribute injection
// from a tampered localStorage (e.g. XSS-written value by another script).
if (stored === 'dark' || stored === 'light') {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
@@ -61,6 +63,7 @@
var newTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, newTheme);
setTheme(newTheme);
announceTheme(newTheme);
});
});
});
@@ -71,4 +74,23 @@
setTheme(e.matches ? 'dark' : 'light');
}
});
/**
* Announce theme change to screen readers via a live region.
*
* @param {string} theme - 'dark' or 'light'
*/
function announceTheme(theme) {
var msg = theme === 'dark' ? 'Dark mode enabled' : 'Light mode enabled';
var el = document.getElementById('wp-bootstrap-theme-status');
if (!el) {
el = document.createElement('div');
el.id = 'wp-bootstrap-theme-status';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.className = 'visually-hidden';
document.body.appendChild(el);
}
el.textContent = msg;
}
})();

View File

@@ -14,11 +14,20 @@
"php": ">=8.3",
"twig/twig": "^3.0"
},
"require-dev": {
"brain/monkey": "^2.6",
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"WPBootstrap\\": "inc/"
}
},
"autoload-dev": {
"psr-4": {
"WPBootstrap\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true

2038
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,9 @@ if ( file_exists( get_template_directory() . '/vendor/autoload.php' ) ) {
*/
if ( ! function_exists( 'wp_bootstrap_setup' ) ) :
function wp_bootstrap_setup() {
// Add support for automatic document title tag.
add_theme_support( 'title-tag' );
// Add support for post formats.
add_theme_support( 'post-formats', array(
'aside', 'audio', 'chat', 'gallery', 'image',
@@ -34,10 +37,36 @@ if ( ! function_exists( 'wp_bootstrap_setup' ) ) :
// Add editor styles.
add_editor_style( 'assets/css/editor-style.css' );
// Register navigation menu locations.
register_nav_menus( array(
'primary' => __( 'Primary Navigation', 'wp-bootstrap' ),
'footer' => __( 'Footer Navigation', 'wp-bootstrap' ),
) );
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_setup' );
/**
* Register widget areas.
*
* @since 1.0.0
*/
if ( ! function_exists( 'wp_bootstrap_register_sidebars' ) ) :
function wp_bootstrap_register_sidebars() {
register_sidebar( array(
'name' => __( 'Sidebar', 'wp-bootstrap' ),
'id' => 'primary-sidebar',
'description' => __( 'Add widgets here to appear in the sidebar.', 'wp-bootstrap' ),
'before_widget' => '<div id="%1$s" class="widget mb-4 %2$s">',
'after_widget' => '</div>',
'before_title' => '<h3 class="sidebar-heading h6 text-uppercase fw-semibold">',
'after_title' => '</h3>',
) );
}
endif;
add_action( 'widgets_init', 'wp_bootstrap_register_sidebars' );
/**
* Enqueue theme scripts and styles.
*/
@@ -54,6 +83,13 @@ if ( ! function_exists( 'wp_bootstrap_enqueue_scripts' ) ) :
$theme_version
);
// Push offcanvas below the WP admin bar when logged in.
if ( is_admin_bar_showing() ) {
wp_add_inline_style( 'wp-bootstrap-style',
'@media (max-width: 991.98px) { .offcanvas { padding-top: var(--wp-admin--admin-bar--height, 32px); } }'
);
}
// Enqueue Bootstrap JS bundle (includes Popper).
wp_enqueue_script(
'wp-bootstrap-js',
@@ -85,6 +121,416 @@ if ( ! function_exists( 'wp_bootstrap_enqueue_scripts' ) ) :
endif;
add_action( 'wp_enqueue_scripts', 'wp_bootstrap_enqueue_scripts' );
/**
* Preload critical fonts for performance.
*
* @since 0.3.0
*/
if ( ! function_exists( 'wp_bootstrap_preload_fonts' ) ) :
function wp_bootstrap_preload_fonts() {
$fonts = array(
'inter/InterVariable.woff2',
'lora/Lora-VariableFont.woff2',
);
$base = get_template_directory_uri() . '/assets/fonts/';
foreach ( $fonts as $font ) {
printf(
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
esc_url( $base . $font )
);
}
}
endif;
add_action( 'wp_head', 'wp_bootstrap_preload_fonts', 1 );
/**
* Enqueue RTL stylesheet for right-to-left languages.
*
* @since 0.3.0
*/
if ( ! function_exists( 'wp_bootstrap_rtl_styles' ) ) :
function wp_bootstrap_rtl_styles() {
if ( ! is_rtl() ) {
return;
}
$theme_version = wp_get_theme()->get( 'Version' );
wp_enqueue_style(
'wp-bootstrap-rtl',
get_template_directory_uri() . '/assets/css/rtl.css',
array( 'wp-bootstrap-style' ),
$theme_version
);
}
endif;
add_action( 'wp_enqueue_scripts', 'wp_bootstrap_rtl_styles', 20 );
/**
* Bridge WordPress style variation colors to Bootstrap CSS custom properties.
*
* Reads the active color palette (theme.json + variation + user overrides)
* and outputs inline CSS for both light and dark modes so the dark-mode toggle
* switches between two variation-aware color schemes.
*
* Theme colors (primaryinfo) go into :root and apply in both modes.
* Surface colors (body-bg, tertiary-bg, etc.) are computed separately for
* [data-bs-theme=light] and [data-bs-theme=dark].
*
* For light palettes: light mode uses base/contrast; dark mode uses dark/light slugs.
* For dark palettes: dark mode uses base/contrast; light mode swaps them.
*
* @since 0.3.2
*/
if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
function wp_bootstrap_variation_colors() {
$transient_key = 'wp_bootstrap_variation_css_' . md5( get_stylesheet() );
$cached_css = get_transient( $transient_key );
if ( false !== $cached_css ) {
// '' means default palette (no inline CSS needed); non-empty string is the computed CSS.
if ( '' !== $cached_css ) {
wp_add_inline_style( 'wp-bootstrap-style', $cached_css );
}
return;
}
// Read the theme origin palette — this contains the base theme.json
// colors merged with the active style variation (if any).
$theme_palette = wp_get_global_settings( array( 'color', 'palette', 'theme' ) );
$colors = array();
if ( ! empty( $theme_palette ) && is_array( $theme_palette ) ) {
foreach ( $theme_palette as $entry ) {
if ( ! empty( $entry['slug'] ) && ! empty( $entry['color'] ) ) {
$colors[ $entry['slug'] ] = $entry['color'];
}
}
}
// Compare against base theme.json defaults to detect an active variation.
// WordPress puts variation colors in the 'theme' origin, not 'custom'.
$base_defaults = array(
'base' => '#ffffff',
'contrast' => '#212529',
'primary' => '#0d6efd',
);
$is_default = true;
foreach ( $base_defaults as $slug => $default_color ) {
if ( ! empty( $colors[ $slug ] ) && strtolower( $colors[ $slug ] ) !== $default_color ) {
$is_default = false;
break;
}
}
// No variation active — let Bootstrap's compiled CSS handle both modes.
if ( $is_default ) {
set_transient( $transient_key, '', DAY_IN_SECONDS );
return;
}
if ( empty( $colors['base'] ) || empty( $colors['contrast'] ) ) {
set_transient( $transient_key, '', DAY_IN_SECONDS );
return;
}
// Theme colors apply in both modes via :root.
$theme_slugs = array(
'primary' => '--bs-primary',
'secondary' => '--bs-secondary',
'success' => '--bs-success',
'danger' => '--bs-danger',
'warning' => '--bs-warning',
'info' => '--bs-info',
);
$root_css = '';
foreach ( $theme_slugs as $slug => $var ) {
if ( ! empty( $colors[ $slug ] ) ) {
$hex = esc_attr( $colors[ $slug ] );
$rgb = wp_bootstrap_hex_to_rgb( $colors[ $slug ] );
$root_css .= "{$var}:{$hex};";
if ( $rgb ) {
$root_css .= "{$var}-rgb:{$rgb};";
}
}
}
// Link colors from primary (both modes).
if ( ! empty( $colors['primary'] ) ) {
$primary_rgb = wp_bootstrap_hex_to_rgb( $colors['primary'] );
$root_css .= '--bs-link-color:' . esc_attr( $colors['primary'] ) . ';';
$root_css .= '--bs-link-color-rgb:' . $primary_rgb . ';';
$root_css .= '--bs-link-hover-color:' . esc_attr( $colors['primary'] ) . ';';
$root_css .= '--bs-link-hover-color-rgb:' . $primary_rgb . ';';
}
// Determine if this is a dark palette (base luminance < contrast luminance).
$is_dark = wp_bootstrap_relative_luminance( $colors['base'] )
< wp_bootstrap_relative_luminance( $colors['contrast'] );
// Resolve light-mode and dark-mode base colors.
if ( $is_dark ) {
// Dark palette: dark mode is native, light mode swaps base↔contrast.
$light_bg = $colors['contrast'];
$light_fg = $colors['base'];
$dark_bg = $colors['base'];
$dark_fg = $colors['contrast'];
} else {
// Light palette: light mode is native, dark mode uses dark/light slugs.
$light_bg = $colors['base'];
$light_fg = $colors['contrast'];
$dark_bg = $colors['dark'] ?? '#212529';
$dark_fg = $colors['light'] ?? '#f8f9fa';
}
// Also set light/dark slug variables for utilities like bg-light, bg-dark.
if ( ! empty( $colors['light'] ) ) {
$root_css .= '--bs-light:' . esc_attr( $colors['light'] ) . ';';
$root_css .= '--bs-light-rgb:' . wp_bootstrap_hex_to_rgb( $colors['light'] ) . ';';
}
if ( ! empty( $colors['dark'] ) ) {
$root_css .= '--bs-dark:' . esc_attr( $colors['dark'] ) . ';';
$root_css .= '--bs-dark-rgb:' . wp_bootstrap_hex_to_rgb( $colors['dark'] ) . ';';
}
// Build surface CSS for a given bg/fg pair.
$light_css = wp_bootstrap_build_surface_css( $light_bg, $light_fg, false );
$dark_css = wp_bootstrap_build_surface_css( $dark_bg, $dark_fg, true );
$css = ':root{' . $root_css . '}'
. '[data-bs-theme=light]{' . $light_css . '}'
. '[data-bs-theme=dark]{' . $dark_css . '}';
// Cache for 24 hours; invalidated on theme switch or global-styles save.
set_transient( $transient_key, $css, DAY_IN_SECONDS );
// Attach after the compiled stylesheet so variation values override
// Bootstrap's hardcoded dark-mode defaults via source order.
wp_add_inline_style( 'wp-bootstrap-style', $css );
}
endif;
add_action( 'wp_enqueue_scripts', 'wp_bootstrap_variation_colors', 30 );
/**
* Invalidate the color variation CSS transient when global styles or theme change.
*/
add_action( 'switch_theme', function () {
delete_transient( 'wp_bootstrap_variation_css_' . md5( get_stylesheet() ) );
} );
add_action( 'save_post_wp_global_styles', function () {
delete_transient( 'wp_bootstrap_variation_css_' . md5( get_stylesheet() ) );
} );
/**
* Invalidate sidebar transient caches when content changes.
*
* @since 1.2.0
*/
add_action( 'save_post', function () {
delete_transient( 'wp_bootstrap_sidebar_recent_4' );
} );
add_action( 'create_post_tag', function () {
delete_transient( 'wp_bootstrap_sidebar_tags_15' );
} );
add_action( 'edit_post_tag', function () {
delete_transient( 'wp_bootstrap_sidebar_tags_15' );
} );
add_action( 'delete_post_tag', function () {
delete_transient( 'wp_bootstrap_sidebar_tags_15' );
} );
/**
* Build Bootstrap surface CSS variables for a given background/foreground pair.
*
* Computes body-bg, body-color, tertiary-bg, secondary-bg, secondary-color,
* emphasis-color, and border-color — mirroring how Bootstrap derives them.
*
* @since 0.3.2
*
* @param string $bg Background hex color.
* @param string $fg Foreground (text) hex color.
* @param bool $is_dark Whether this is for dark mode.
* @return string CSS declarations (no selector).
*/
if ( ! function_exists( 'wp_bootstrap_build_surface_css' ) ) :
function wp_bootstrap_build_surface_css( $bg, $fg, $is_dark ) {
$bg_rgb = wp_bootstrap_hex_to_rgb( $bg );
$fg_rgb = wp_bootstrap_hex_to_rgb( $fg );
$css = '--bs-body-bg:' . esc_attr( $bg ) . ';';
$css .= '--bs-body-bg-rgb:' . $bg_rgb . ';';
$css .= '--bs-body-color:' . esc_attr( $fg ) . ';';
$css .= '--bs-body-color-rgb:' . $fg_rgb . ';';
if ( $is_dark ) {
$secondary_bg = wp_bootstrap_mix_hex( $fg, $bg, 0.16 );
$tertiary_bg = wp_bootstrap_mix_hex( $fg, $bg, 0.08 );
$border_color = wp_bootstrap_mix_hex( $fg, $bg, 0.24 );
$emphasis = '#FFFFFF';
} else {
$secondary_bg = wp_bootstrap_mix_hex( $fg, $bg, 0.08 );
$tertiary_bg = wp_bootstrap_mix_hex( $fg, $bg, 0.04 );
$border_color = wp_bootstrap_mix_hex( $fg, $bg, 0.16 );
$emphasis = '#000000';
}
$css .= '--bs-secondary-color:rgba(' . $fg_rgb . ',0.75);';
$css .= '--bs-secondary-bg:' . $secondary_bg . ';';
$css .= '--bs-secondary-bg-rgb:' . wp_bootstrap_hex_to_rgb( $secondary_bg ) . ';';
$css .= '--bs-tertiary-color:rgba(' . $fg_rgb . ',0.5);';
$css .= '--bs-tertiary-bg:' . $tertiary_bg . ';';
$css .= '--bs-tertiary-bg-rgb:' . wp_bootstrap_hex_to_rgb( $tertiary_bg ) . ';';
$css .= '--bs-emphasis-color:' . $emphasis . ';';
$css .= '--bs-emphasis-color-rgb:' . wp_bootstrap_hex_to_rgb( $emphasis ) . ';';
$css .= '--bs-border-color:' . $border_color . ';';
return $css;
}
endif;
/**
* Convert a hex color string to an RGB triplet string.
*
* @since 0.3.2
*
* @param string $hex Hex color (e.g. "#0d6efd" or "0d6efd").
* @return string RGB triplet (e.g. "13,110,253") or empty string on failure.
*/
if ( ! function_exists( 'wp_bootstrap_hex_to_rgb' ) ) :
function wp_bootstrap_hex_to_rgb( string $hex ): string {
$rgb = wp_bootstrap_hex_to_rgb_array( $hex );
if ( ! $rgb ) {
return '';
}
return implode( ',', $rgb );
}
endif;
/**
* Mix two hex colors by a given weight.
*
* @since 0.3.2
*
* @param string $color1 Hex color to mix in.
* @param string $color2 Base hex color.
* @param float $weight Weight of color1 (0.0 to 1.0).
* @return string Resulting hex color.
*/
if ( ! function_exists( 'wp_bootstrap_mix_hex' ) ) :
function wp_bootstrap_mix_hex( string $color1, string $color2, float $weight ): string {
$c1 = wp_bootstrap_hex_to_rgb_array( $color1 );
$c2 = wp_bootstrap_hex_to_rgb_array( $color2 );
if ( ! $c1 || ! $c2 ) {
return $color2;
}
$r = (int) round( $c1[0] * $weight + $c2[0] * ( 1 - $weight ) );
$g = (int) round( $c1[1] * $weight + $c2[1] * ( 1 - $weight ) );
$b = (int) round( $c1[2] * $weight + $c2[2] * ( 1 - $weight ) );
return sprintf( '#%02x%02x%02x', max( 0, min( 255, $r ) ), max( 0, min( 255, $g ) ), max( 0, min( 255, $b ) ) );
}
endif;
/**
* Convert a hex color to an array of [r, g, b] integers.
*
* @since 0.3.2
*
* @param string $hex Hex color.
* @return array|false Array of [r, g, b] or false on failure.
*/
if ( ! function_exists( 'wp_bootstrap_hex_to_rgb_array' ) ) :
function wp_bootstrap_hex_to_rgb_array( string $hex ): array|false {
$hex = ltrim( $hex, '#' );
if ( strlen( $hex ) === 3 ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) {
return false;
}
return array(
hexdec( substr( $hex, 0, 2 ) ),
hexdec( substr( $hex, 2, 2 ) ),
hexdec( substr( $hex, 4, 2 ) ),
);
}
endif;
/**
* Compute relative luminance of a hex color (0.0 = black, 1.0 = white).
*
* @since 0.3.2
*
* @param string $hex Hex color.
* @return float Relative luminance.
*/
if ( ! function_exists( 'wp_bootstrap_relative_luminance' ) ) :
function wp_bootstrap_relative_luminance( string $hex ): float {
$rgb = wp_bootstrap_hex_to_rgb_array( $hex );
if ( ! $rgb ) {
return 0.0;
}
$channels = array();
foreach ( $rgb as $val ) {
$val /= 255;
$channels[] = ( $val <= 0.03928 ) ? $val / 12.92 : pow( ( $val + 0.055 ) / 1.055, 2.4 );
}
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
}
endif;
/**
* Enqueue Bootstrap JS in the block editor for interactive previews.
*
* @since 0.2.0
*/
if ( ! function_exists( 'wp_bootstrap_enqueue_editor_assets' ) ) :
function wp_bootstrap_enqueue_editor_assets() {
$theme_version = wp_get_theme()->get( 'Version' );
wp_enqueue_script(
'wp-bootstrap-editor-js',
get_template_directory_uri() . '/assets/js/bootstrap.bundle.min.js',
array(),
$theme_version,
true
);
}
endif;
add_action( 'enqueue_block_editor_assets', 'wp_bootstrap_enqueue_editor_assets' );
/**
* Register custom block categories for Bootstrap components.
*
* @since 0.2.0
*
* @param array $categories Existing block categories.
* @param WP_Block_Editor_Context $context Block editor context.
* @return array Modified categories.
*/
if ( ! function_exists( 'wp_bootstrap_block_categories' ) ) :
function wp_bootstrap_block_categories( array $categories, $context ): array {
$bootstrap_categories = array(
array(
'slug' => 'wp-bootstrap-layout',
'title' => __( 'Bootstrap Layout', 'wp-bootstrap' ),
'icon' => 'layout',
),
array(
'slug' => 'wp-bootstrap-components',
'title' => __( 'Bootstrap Components', 'wp-bootstrap' ),
'icon' => 'grid-view',
),
array(
'slug' => 'wp-bootstrap-navigation',
'title' => __( 'Bootstrap Navigation', 'wp-bootstrap' ),
'icon' => 'menu',
),
);
return array_merge( $bootstrap_categories, $categories );
}
endif;
add_filter( 'block_categories_all', 'wp_bootstrap_block_categories', 10, 2 );
/**
* Register block pattern categories.
*/
@@ -153,6 +599,30 @@ if ( ! function_exists( 'wp_bootstrap_pattern_categories' ) ) :
'description' => __( 'Text-focused content sections.', 'wp-bootstrap' ),
)
);
register_block_pattern_category(
'wp-bootstrap-layout',
array(
'label' => __( 'Layout', 'wp-bootstrap' ),
'description' => __( 'Layout building blocks for page structure.', 'wp-bootstrap' ),
)
);
register_block_pattern_category(
'wp-bootstrap-components',
array(
'label' => __( 'Components', 'wp-bootstrap' ),
'description' => __( 'Reusable Bootstrap component patterns.', 'wp-bootstrap' ),
)
);
register_block_pattern_category(
'wp-bootstrap-navigation',
array(
'label' => __( 'Navigation', 'wp-bootstrap' ),
'description' => __( 'Navigation and header patterns.', 'wp-bootstrap' ),
)
);
}
endif;
add_action( 'init', 'wp_bootstrap_pattern_categories' );
@@ -178,7 +648,7 @@ if ( ! function_exists( 'wp_bootstrap_block_styles' ) ) :
'name' => 'list-unstyled',
'label' => __( 'Unstyled', 'wp-bootstrap' ),
'inline_style' => '
ul.is-style-list-unstyled, ol.is-style-list-unstyled { list-style: none; padding-left: 0; }',
ul.is-style-list-unstyled, ol.is-style-list-unstyled { list-style: none; padding-inline-start: 0; }',
) );
// core/group - Card.
@@ -258,7 +728,7 @@ if ( ! function_exists( 'wp_bootstrap_block_styles' ) ) :
'name' => 'blockquote-accent',
'label' => __( 'Accent Border', 'wp-bootstrap' ),
'inline_style' => '
.is-style-blockquote-accent { border-left: 4px solid var(--wp--preset--color--primary); background: var(--wp--preset--color--light); padding: var(--wp--preset--spacing--30); border-radius: 0 0.375rem 0.375rem 0; }',
.is-style-blockquote-accent { border-inline-start: 4px solid var(--wp--preset--color--primary); background: var(--wp--preset--color--light); padding: var(--wp--preset--spacing--30); border-start-start-radius: 0; border-start-end-radius: 0.375rem; border-end-end-radius: 0.375rem; border-end-start-radius: 0; }',
) );
// core/image - Shadow.
@@ -300,6 +770,12 @@ if ( ! function_exists( 'wp_bootstrap_block_styles' ) ) :
'inline_style' => '
.is-style-separator-wide { max-width: none; }',
) );
// core/list - List Group (Bootstrap).
register_block_style( 'core/list', array(
'name' => 'list-group',
'label' => __( 'List Group', 'wp-bootstrap' ),
) );
}
endif;
add_action( 'init', 'wp_bootstrap_block_styles' );
@@ -315,3 +791,96 @@ if ( ! function_exists( 'wp_bootstrap_init_twig' ) ) :
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_twig' );
/**
* Initialize Twig template controller for frontend rendering.
*
* Hooks into template_redirect to render Bootstrap 5 HTML
* via Twig templates instead of FSE block markup on the frontend.
*
* @since 0.1.1
*/
if ( ! function_exists( 'wp_bootstrap_init_templates' ) ) :
function wp_bootstrap_init_templates() {
if ( class_exists( '\\WPBootstrap\\Template\\TemplateController' ) ) {
new \WPBootstrap\Template\TemplateController();
}
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_templates' );
/**
* Initialize block renderer for Bootstrap 5 class injection.
*
* Hooks per-block render_block filters to inject Bootstrap classes
* (e.g. .table, .btn, .img-fluid) into core block HTML on the frontend.
*
* @since 1.1.0
*/
if ( ! function_exists( 'wp_bootstrap_init_block_renderer' ) ) :
function wp_bootstrap_init_block_renderer() {
if ( class_exists( '\\WPBootstrap\\Block\\BlockRenderer' ) ) {
new \WPBootstrap\Block\BlockRenderer();
}
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_block_renderer' );
/**
* Initialize widget renderer for Bootstrap 5 card wrappers.
*
* Hooks dynamic_sidebar_params to wrap sidebar widgets in Bootstrap
* card components with proper heading structure.
*
* @since 1.1.0
*/
if ( ! function_exists( 'wp_bootstrap_init_widget_renderer' ) ) :
function wp_bootstrap_init_widget_renderer() {
if ( class_exists( '\\WPBootstrap\\Block\\WidgetRenderer' ) ) {
new \WPBootstrap\Block\WidgetRenderer();
}
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_widget_renderer' );
/**
* Customize comment form fields with Bootstrap classes.
*
* @since 0.1.1
*
* @param array $fields Default comment form fields.
* @return array Modified fields with Bootstrap classes.
*/
if ( ! function_exists( 'wp_bootstrap_comment_form_fields' ) ) :
function wp_bootstrap_comment_form_fields( array $fields ): array {
$commenter = wp_get_current_commenter();
$required = get_option( 'require_name_email' );
$req_attr = $required ? ' required' : '';
$fields['author'] = '<div class="mb-3">'
. '<label for="author" class="form-label">' . __( 'Name', 'wp-bootstrap' ) . ( $required ? ' <span class="text-danger">*</span>' : '' ) . '</label>'
. '<input id="author" name="author" type="text" class="form-control" value="' . esc_attr( $commenter['comment_author'] ) . '"' . $req_attr . '>'
. '</div>';
$fields['email'] = '<div class="mb-3">'
. '<label for="email" class="form-label">' . __( 'Email', 'wp-bootstrap' ) . ( $required ? ' <span class="text-danger">*</span>' : '' ) . '</label>'
. '<input id="email" name="email" type="email" class="form-control" value="' . esc_attr( $commenter['comment_author_email'] ) . '"' . $req_attr . '>'
. '</div>';
$fields['url'] = '<div class="mb-3">'
. '<label for="url" class="form-label">' . __( 'Website', 'wp-bootstrap' ) . '</label>'
. '<input id="url" name="url" type="url" class="form-control" value="' . esc_attr( $commenter['comment_author_url'] ) . '">'
. '</div>';
if ( isset( $fields['cookies'] ) ) {
$fields['cookies'] = '<div class="mb-3 form-check">'
. '<input id="wp-comment-cookies-consent" name="wp-comment-cookies-consent" type="checkbox" class="form-check-input" value="yes">'
. '<label for="wp-comment-cookies-consent" class="form-check-label">'
. __( 'Save my name, email, and website in this browser for the next time I comment.', 'wp-bootstrap' )
. '</label></div>';
}
return $fields;
}
endif;
add_filter( 'comment_form_default_fields', 'wp_bootstrap_comment_form_fields' );

303
inc/Block/BlockRenderer.php Normal file
View File

@@ -0,0 +1,303 @@
<?php
/**
* Block Renderer.
*
* Injects Bootstrap 5 classes into WordPress core block HTML output
* on the frontend. Uses WP_HTML_Tag_Processor for safe HTML manipulation.
*
* @package WPBootstrap\Block
* @since 1.1.0
*/
namespace WPBootstrap\Block;
use WP_HTML_Tag_Processor;
use WP_Block;
class BlockRenderer
{
/**
* Map of WordPress preset color slugs to Bootstrap button variants.
*/
private const COLOR_VARIANTS = [
'primary' => 'primary',
'secondary' => 'secondary',
'success' => 'success',
'danger' => 'danger',
'warning' => 'warning',
'info' => 'info',
'light' => 'light',
'dark' => 'dark',
];
/**
* Register render_block filters for each supported block type.
*/
public function __construct()
{
if ( is_admin() || wp_doing_ajax() ) {
return;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
$blocks = $this->getBlockHandlers();
/**
* Filters the map of block names to handler method names.
*
* Child themes can remove blocks or add new ones.
*
* @since 1.1.0
*
* @param array $blocks Map of 'core/block-name' => 'methodName'.
*/
$blocks = apply_filters( 'wp_bootstrap_block_renderer_blocks', $blocks );
foreach ( $blocks as $blockName => $method ) {
if ( method_exists( $this, $method ) ) {
add_filter( "render_block_{$blockName}", [ $this, $method ], 10, 3 );
}
}
}
/**
* Get the default map of block names to handler methods.
*
* @return array<string, string>
*/
private function getBlockHandlers(): array
{
return [
'core/table' => 'renderTable',
'core/button' => 'renderButton',
'core/buttons' => 'renderButtons',
'core/image' => 'renderImage',
'core/search' => 'renderSearch',
'core/quote' => 'renderQuote',
'core/pullquote' => 'renderPullquote',
'core/list' => 'renderList',
];
}
/**
* Add Bootstrap table classes.
*
* Injects .table on <table>; adds .table-striped when WP stripes style is active.
*/
public function renderTable( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag( 'table' ) ) {
return $content;
}
$processor->add_class( 'table' );
$className = $block['attrs']['className'] ?? '';
if ( str_contains( $className, 'is-style-stripes' ) ) {
$processor->add_class( 'table-striped' );
}
return $processor->get_updated_html();
}
/**
* Add Bootstrap button classes.
*
* Injects .btn + color variant on .wp-block-button__link.
* Maps WP preset color slugs to Bootstrap btn-{variant} / btn-outline-{variant}.
*/
public function renderButton( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag( [ 'class_name' => 'wp-block-button__link' ] ) ) {
return $content;
}
$processor->add_class( 'btn' );
$attrs = $block['attrs'] ?? [];
// Gradient buttons: just .btn, inline style handles the color.
if ( ! empty( $attrs['gradient'] ) ) {
return $processor->get_updated_html();
}
$className = $attrs['className'] ?? '';
$isOutline = str_contains( $className, 'is-style-outline' );
if ( $isOutline ) {
$colorSlug = $attrs['textColor'] ?? 'primary';
$variant = self::COLOR_VARIANTS[ $colorSlug ] ?? 'primary';
$processor->add_class( 'btn-outline-' . $variant );
} else {
$colorSlug = $attrs['backgroundColor'] ?? 'primary';
$variant = self::COLOR_VARIANTS[ $colorSlug ] ?? 'primary';
$processor->add_class( 'btn-' . $variant );
}
return $processor->get_updated_html();
}
/**
* Add Bootstrap flex utilities to button group wrapper.
*/
public function renderButtons( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag( [ 'class_name' => 'wp-block-buttons' ] ) ) {
return $content;
}
$processor->add_class( 'd-flex' );
$processor->add_class( 'flex-wrap' );
$processor->add_class( 'gap-2' );
return $processor->get_updated_html();
}
/**
* Add .img-fluid to block images.
*/
public function renderImage( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag( 'img' ) ) {
return $content;
}
$processor->add_class( 'img-fluid' );
return $processor->get_updated_html();
}
/**
* Add Bootstrap form-control and button classes to search block.
*/
public function renderSearch( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
// Add .input-group to the inner wrapper for seamless input + button.
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__inside-wrapper' ] ) ) {
$processor->add_class( 'input-group' );
}
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__input' ] ) ) {
$processor->add_class( 'form-control' );
}
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__button' ] ) ) {
$processor->add_class( 'btn' );
$processor->add_class( 'btn-primary' );
}
return $processor->get_updated_html();
}
/**
* Add Bootstrap blockquote classes to quote block.
*/
public function renderQuote( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag( 'blockquote' ) ) {
return $content;
}
$processor->add_class( 'blockquote' );
if ( $processor->next_tag( 'cite' ) ) {
$processor->add_class( 'blockquote-footer' );
}
return $processor->get_updated_html();
}
/**
* Add Bootstrap blockquote classes to pullquote block.
*/
public function renderPullquote( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag( 'blockquote' ) ) {
return $content;
}
$processor->add_class( 'blockquote' );
if ( $processor->next_tag( 'cite' ) ) {
$processor->add_class( 'blockquote-footer' );
}
return $processor->get_updated_html();
}
/**
* Add Bootstrap list-group classes when list-group style is selected.
*
* Only modifies lists with the is-style-list-group block style.
*/
public function renderList( string $content, array $block, ?WP_Block $instance = null ): string
{
if ( empty( $content ) ) {
return $content;
}
$className = $block['attrs']['className'] ?? '';
if ( ! str_contains( $className, 'is-style-list-group' ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
$listTag = ! empty( $block['attrs']['ordered'] ) ? 'ol' : 'ul';
if ( $processor->next_tag( $listTag ) ) {
$processor->add_class( 'list-group' );
}
while ( $processor->next_tag( 'li' ) ) {
$processor->add_class( 'list-group-item' );
}
return $processor->get_updated_html();
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* Widget Renderer.
*
* Transforms sidebar widget wrappers into Bootstrap 5 card components
* and adjusts block widget content (headings, lists) for Bootstrap styling.
*
* Card structure:
* With title: card → card-body → h4.card-title → content
* Without title: card → card-body → content
*
* The title is placed inside card-body as a card-title. This avoids
* broken HTML when widgets omit the title (WordPress skips before_title
* and after_title entirely when there is no title to output).
*
* @package WPBootstrap\Block
* @since 1.1.0
*/
namespace WPBootstrap\Block;
use WP_HTML_Tag_Processor;
class WidgetRenderer
{
/**
* Register filters for widget output transformation.
*/
public function __construct()
{
if ( is_admin() || wp_doing_ajax() ) {
return;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
add_filter( 'dynamic_sidebar_params', [ $this, 'wrapWidgetInCard' ] );
add_filter( 'widget_block_content', [ $this, 'processBlockWidgetContent' ], 10, 3 );
}
/**
* Restructure widget wrapper as a Bootstrap card.
*
* Uses card-body for all content with card-title for the heading.
* This structure works correctly whether or not the widget outputs a title.
*
* Note: WordPress runs sprintf on before_widget BEFORE this filter,
* so %1$s/%2$s are already replaced. We must use the processed values.
*
* @param array $params Sidebar parameters.
* @return array Modified parameters.
*/
public function wrapWidgetInCard( array $params ): array
{
$widgetId = $params[0]['widget_id'] ?? '';
$beforeWidget = $params[0]['before_widget'] ?? '';
// Extract widget-type classes (e.g. widget_block, widget_search)
// from the already-processed before_widget, skipping generic
// wrapper classes that we're replacing.
$widgetClasses = '';
if ( preg_match( '/class="([^"]*)"/', $beforeWidget, $matches ) ) {
$original = array_filter( explode( ' ', $matches[1] ) );
$skip = [ 'widget', 'mb-4' ];
$kept = array_diff( $original, $skip );
$widgetClasses = implode( ' ', $kept );
}
$params[0]['before_widget'] = sprintf(
'<div id="%s" class="card mb-3 widget %s"><div class="card-body">',
esc_attr( $widgetId ),
esc_attr( $widgetClasses )
);
$params[0]['after_widget'] = '</div></div>';
$params[0]['before_title'] = '<h4 class="card-title h6 text-uppercase fw-semibold">';
$params[0]['after_title'] = '</h4>';
return $params;
}
/**
* Process block widget content to downgrade h2 headings to h4.
*
* Block widgets render their headings as <h2 class="wp-block-heading">.
* Inside a sidebar card, h2 is too large — replace with h4 for proper
* visual hierarchy.
*
* @param string $content Widget block content.
* @param array $instance Widget instance data.
* @param \WP_Widget $widget Widget object.
* @return string Modified content.
*/
public function processBlockWidgetContent( string $content, array $instance, \WP_Widget $widget ): string
{
if ( empty( $content ) ) {
return $content;
}
// Replace <h2 ... wp-block-heading ...>...</h2> with <h4> pairs.
// Single regex ensures only headings with wp-block-heading class are
// downgraded, preventing mismatched tags if a widget contains other h2s.
$content = preg_replace(
'/<h2(\s+class="[^"]*wp-block-heading[^"]*"[^>]*)>(.*?)<\/h2>/s',
'<h4$1>$2</h4>',
$content
);
return $content;
}
}

View File

@@ -0,0 +1,567 @@
<?php
/**
* Twig Template Context Builder.
*
* Gathers WordPress data into structured arrays for Twig templates.
*
* @package WPBootstrap
* @since 0.1.1
*/
namespace WPBootstrap\Template;
class ContextBuilder
{
private NavWalker $navWalker;
public function __construct()
{
$this->navWalker = new NavWalker();
}
/**
* Build the complete context for the current request.
*/
public function build(): array
{
$context = [
'site' => $this->getSiteData(),
'menu' => $this->getMenuData('primary'),
'footer_menu' => $this->getMenuData('footer'),
'dark_mode' => true,
'layout' => 'default',
'header_variant' => $this->getHeaderVariant(),
'footer_variant' => $this->getFooterVariant(),
'user' => $this->getUserData(),
];
if (is_singular()) {
$context['post'] = $this->getPostData();
if (is_singular('post')) {
$context['comments'] = $this->getCommentsData();
$context['post_navigation'] = $this->getPostNavigation();
$context['more_posts'] = $this->getMorePosts();
}
}
if (is_home() || is_archive() || is_search()) {
$context['posts'] = $this->getPostsLoop();
$context['pagination'] = $this->getPagination();
}
if (is_archive()) {
$context['archive'] = $this->getArchiveData();
}
if (is_search()) {
$context['search_query'] = get_search_query();
}
// Sidebar: determine once whether the current page needs sidebar data.
$needsSidebar = false;
if (is_home()) {
$pageId = (int) get_option('page_for_posts');
if ($pageId) {
$templateSlug = get_page_template_slug($pageId);
if ($templateSlug === 'home-sidebar') {
$context['layout'] = 'sidebar';
}
}
$needsSidebar = true;
} elseif (is_singular('post')) {
$needsSidebar = true;
} elseif (is_page() && get_page_template_slug() === 'page-sidebar') {
$needsSidebar = true;
}
if ($needsSidebar) {
$context['sidebar'] = $this->getSidebarData();
}
return $context;
}
/**
* Get global site information.
*/
private function getSiteData(): array
{
return [
'name' => get_bloginfo('name'),
'description' => get_bloginfo('description'),
'url' => home_url('/'),
'charset' => get_bloginfo('charset'),
];
}
/**
* Get current user data for header/navigation.
*/
private function getUserData(): array
{
if (! is_user_logged_in()) {
return ['logged_in' => false];
}
$user = wp_get_current_user();
$account_url = function_exists('wc_get_page_permalink')
? wc_get_page_permalink('myaccount')
: admin_url('profile.php');
return [
'logged_in' => true,
'display_name' => $user->display_name,
'avatar' => get_avatar($user->ID, 32, '', '', ['class' => 'rounded-circle']),
'account_url' => $account_url,
];
}
/**
* Get navigation menu items for a location.
*/
private function getMenuData(string $location): array
{
$locations = get_nav_menu_locations();
if (isset($locations[$location])) {
$menu = wp_get_nav_menu_object($locations[$location]);
if ($menu) {
$items = wp_get_nav_menu_items($menu->term_id);
if ($items) {
return $this->navWalker->buildTree($items);
}
}
}
// Fallback for primary: list top-level pages.
if ($location === 'primary') {
return $this->getPagesFallback();
}
return [];
}
/**
* Fallback menu from published pages.
*/
private function getPagesFallback(): array
{
$pages = get_pages([
'sort_column' => 'menu_order,post_title',
'parent' => 0,
]);
$items = [];
foreach ($pages as $page) {
$items[] = [
'id' => $page->ID,
'title' => $page->post_title,
'url' => get_permalink($page->ID),
'target' => '',
'classes' => '',
'active' => is_page($page->ID),
'children' => [],
];
}
return $items;
}
/**
* Get single post/page data.
*/
private function getPostData(): array
{
global $post;
return [
'id' => $post->ID,
'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(),
'content' => apply_filters('the_content', get_the_content()),
'excerpt' => get_the_excerpt(),
'date' => get_the_date(),
'date_iso' => get_the_date('c'),
'modified' => get_the_modified_date(),
'author' => [
'name' => get_the_author(),
'url' => get_author_posts_url(get_the_author_meta('ID')),
],
'thumbnail' => get_the_post_thumbnail_url($post->ID, 'large') ?: '',
'categories' => $this->getTermsList('category'),
'tags' => $this->getTermsList('post_tag'),
'type' => get_post_type(),
];
}
/**
* Get posts for the main query loop.
*/
private function getPostsLoop(): array
{
global $wp_query;
$posts = [];
if ($wp_query->have_posts()) {
while ($wp_query->have_posts()) {
$wp_query->the_post();
$posts[] = [
'id' => get_the_ID(),
'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(),
'excerpt' => get_the_excerpt(),
'date' => get_the_date(),
'date_iso' => get_the_date('c'),
'author' => [
'name' => get_the_author(),
'url' => get_author_posts_url(get_the_author_meta('ID')),
],
'thumbnail' => get_the_post_thumbnail_url(null, 'medium_large') ?: '',
'categories' => $this->getTermsList('category'),
'tags' => $this->getTermsList('post_tag'),
'read_more' => __('Read more', 'wp-bootstrap'),
];
}
wp_reset_postdata();
}
return $posts;
}
/**
* Build pagination data.
*/
private function getPagination(): array
{
global $wp_query;
$totalPages = (int) $wp_query->max_num_pages;
$currentPage = max(1, get_query_var('paged'));
if ($totalPages <= 1) {
return [];
}
$pages = [];
for ($i = 1; $i <= $totalPages; $i++) {
$pages[] = [
'number' => $i,
'url' => get_pagenum_link($i),
'is_current' => ($i === $currentPage),
];
}
return [
'pages' => $pages,
'current' => $currentPage,
'total' => $totalPages,
'prev_url' => ($currentPage > 1) ? get_pagenum_link($currentPage - 1) : null,
'next_url' => ($currentPage < $totalPages) ? get_pagenum_link($currentPage + 1) : null,
'prev_text' => __('Previous', 'wp-bootstrap'),
'next_text' => __('Next', 'wp-bootstrap'),
];
}
/**
* Get archive page data.
*/
private function getArchiveData(): array
{
return [
// wp_kses_post() allows safe HTML (headings, links, spans) while stripping
// script/event-handler attributes that could be injected via term descriptions.
'title' => wp_kses_post(get_the_archive_title()),
'description' => wp_kses_post(get_the_archive_description()),
];
}
/**
* Get comments for the current post.
*/
private function getCommentsData(): array
{
$postId = get_the_ID();
$count = (int) get_comments_number($postId);
$comments = get_comments([
'post_id' => $postId,
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
]);
return [
'list' => $this->buildCommentTree($comments),
'count' => $count,
'title' => sprintf(
_n('%s Comment', '%s Comments', $count, 'wp-bootstrap'),
number_format_i18n($count)
),
'form' => $this->getCommentFormHtml(),
'is_open' => comments_open($postId),
];
}
/**
* Build a nested comment tree from flat comments.
*
* Uses a parent-indexed lookup map for O(n) performance instead of
* scanning all comments at each recursion level (O(n^2)).
*/
private function buildCommentTree(array $comments, int $parentId = 0, ?array $index = null): array
{
if ($index === null) {
$index = [];
foreach ($comments as $comment) {
$parent = (int) $comment->comment_parent;
$index[$parent][] = $comment;
}
}
$tree = [];
foreach ($index[$parentId] ?? [] as $comment) {
$tree[] = [
'id' => (int) $comment->comment_ID,
'author' => esc_html($comment->comment_author),
'author_url' => esc_url($comment->comment_author_url),
'avatar_url' => get_avatar_url($comment, ['size' => 40]),
'date' => get_comment_date('', $comment),
'date_iso' => get_comment_date('c', $comment),
'content' => apply_filters('comment_text', $comment->comment_content, $comment),
'edit_url' => current_user_can('edit_comment', $comment->comment_ID)
? get_edit_comment_link($comment)
: '',
'reply_url' => get_comment_reply_link([
'depth' => 1,
'max_depth' => get_option('thread_comments_depth', 5),
], $comment),
'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID, $index),
];
}
return $tree;
}
/**
* Capture the WordPress comment form HTML.
*/
private function getCommentFormHtml(): string
{
if (! comments_open()) {
return '';
}
ob_start();
comment_form([
'title_reply' => __('Leave a Comment', 'wp-bootstrap'),
'title_reply_before' => '<h3 id="reply-title" class="comment-reply-title h5 mb-3">',
'title_reply_after' => '</h3>',
'class_form' => 'needs-validation',
'class_submit' => 'btn btn-primary',
'submit_button' => '<input name="%1$s" type="submit" id="%2$s" class="%3$s" value="%4$s" />',
'comment_field' => '<div class="mb-3"><label for="comment" class="form-label">' . __('Comment', 'wp-bootstrap') . '</label><textarea id="comment" name="comment" class="form-control" rows="5" required></textarea></div>',
]);
return ob_get_clean();
}
/**
* Get previous/next post navigation.
*/
private function getPostNavigation(): array
{
$prev = get_previous_post();
$next = get_next_post();
$navigation = [];
if ($prev) {
$navigation['previous'] = [
'title' => wp_specialchars_decode( get_the_title($prev) ),
'url' => get_permalink($prev),
];
}
if ($next) {
$navigation['next'] = [
'title' => wp_specialchars_decode( get_the_title($next) ),
'url' => get_permalink($next),
];
}
return $navigation;
}
/**
* Get recent posts for the "More posts" section.
*/
private function getMorePosts(int $count = 3): array
{
$currentId = get_the_ID();
$query = new \WP_Query([
'posts_per_page' => $count,
'post__not_in' => [$currentId],
'orderby' => 'date',
'order' => 'DESC',
]);
$posts = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$posts[] = [
'id' => get_the_ID(),
'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(),
'date' => get_the_date(),
'date_iso' => get_the_date('c'),
'thumbnail' => get_the_post_thumbnail_url(null, 'medium_large') ?: '',
];
}
wp_reset_postdata();
}
return $posts;
}
/**
* Get sidebar widget data.
*
* If the 'primary-sidebar' widget area has widgets assigned,
* their rendered HTML is returned. Otherwise, fallback data
* (recent posts, tags) is provided for the default Twig sidebar.
*/
private function getSidebarData(): array
{
$widgets_active = is_active_sidebar( 'primary-sidebar' );
$widgets_html = '';
if ( $widgets_active ) {
ob_start();
dynamic_sidebar( 'primary-sidebar' );
$widgets_html = ob_get_clean();
}
return [
'widgets_active' => $widgets_active,
'widgets_html' => $widgets_html,
'recent_posts' => $this->getSidebarRecentPosts(),
'tags' => $this->getSidebarTags(),
];
}
/**
* Get recent posts for sidebar.
*
* Cached via transient for 1 hour; invalidated on save_post.
*/
private function getSidebarRecentPosts(int $count = 4): array
{
$transient_key = 'wp_bootstrap_sidebar_recent_' . $count;
$cached = get_transient($transient_key);
if (false !== $cached) {
return $cached;
}
$query = new \WP_Query([
'posts_per_page' => $count,
'orderby' => 'date',
'order' => 'DESC',
]);
$posts = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$posts[] = [
'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(),
'date' => get_the_date(),
];
}
wp_reset_postdata();
}
set_transient($transient_key, $posts, HOUR_IN_SECONDS);
return $posts;
}
/**
* Get tags for sidebar tag cloud.
*
* Cached via transient for 1 hour; invalidated on tag changes.
*/
private function getSidebarTags(int $count = 15): array
{
$transient_key = 'wp_bootstrap_sidebar_tags_' . $count;
$cached = get_transient($transient_key);
if (false !== $cached) {
return $cached;
}
$tags = get_tags([
'number' => $count,
'orderby' => 'count',
'order' => 'DESC',
]);
if (! $tags || is_wp_error($tags)) {
return [];
}
$items = [];
foreach ($tags as $tag) {
$items[] = [
'name' => $tag->name,
'url' => get_tag_link($tag->term_id),
'count' => $tag->count,
];
}
set_transient($transient_key, $items, HOUR_IN_SECONDS);
return $items;
}
/**
* Get the active header variant.
*
* @since 0.2.0
*/
private function getHeaderVariant(): string
{
return get_theme_mod('wp_bootstrap_header_variant', 'default');
}
/**
* Get the active footer variant.
*
* @since 0.2.0
*/
private function getFooterVariant(): string
{
return get_theme_mod('wp_bootstrap_footer_variant', 'default');
}
/**
* Get terms list for a taxonomy.
*/
private function getTermsList(string $taxonomy): array
{
$terms = get_the_terms(get_the_ID(), $taxonomy);
if (! $terms || is_wp_error($terms)) {
return [];
}
$list = [];
foreach ($terms as $term) {
$list[] = [
'name' => $term->name,
'url' => get_term_link($term),
];
}
return $list;
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Bootstrap 5 Navigation Walker.
*
* Converts flat WordPress menu items into a nested tree
* suitable for rendering Bootstrap navbar dropdowns in Twig.
*
* @package WPBootstrap
* @since 0.1.1
*/
namespace WPBootstrap\Template;
class NavWalker
{
/**
* Build a nested menu tree from flat WordPress menu items.
*
* @param array $items Array of WP_Post menu item objects.
* @return array Nested array suitable for Twig iteration.
*/
public function buildTree(array $items): array
{
$tree = [];
$children = [];
foreach ($items as $item) {
$node = [
'id' => (int) $item->ID,
'title' => $item->title,
'url' => $item->url,
'target' => $item->target ?: '',
'classes' => implode(' ', array_filter($item->classes ?? [])),
'active' => $this->isActive($item),
'children' => [],
];
if ((int) $item->menu_item_parent === 0) {
$tree[$item->ID] = $node;
} else {
$children[(int) $item->menu_item_parent][] = $node;
}
}
// Assign children to their parent items.
foreach ($children as $parentId => $childItems) {
if (isset($tree[$parentId])) {
$tree[$parentId]['children'] = $childItems;
}
}
return array_values($tree);
}
/**
* Determine if a menu item is currently active.
*/
private function isActive(object $item): bool
{
$classes = $item->classes ?? [];
if (in_array('current-menu-item', $classes, true)) {
return true;
}
if (in_array('current-menu-ancestor', $classes, true)) {
return true;
}
if ($item->object === 'page' && is_page((int) $item->object_id)) {
return true;
}
if ($item->object === 'category' && is_category((int) $item->object_id)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Template Controller.
*
* Intercepts frontend requests and renders Twig templates
* with proper Bootstrap 5 HTML instead of FSE block markup.
*
* @package WPBootstrap
* @since 0.1.1
*/
namespace WPBootstrap\Template;
use WPBootstrap\Twig\TwigService;
class TemplateController
{
private ContextBuilder $contextBuilder;
public function __construct()
{
$this->contextBuilder = new ContextBuilder();
add_action('template_redirect', [$this, 'render']);
}
/**
* Render the appropriate Twig template for the current request.
*/
public function render(): void
{
// Skip admin, REST API, and AJAX requests.
if (is_admin() || wp_doing_ajax()) {
return;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
// Allow plugins or child themes to prevent rendering for this request.
if (! apply_filters('wp_bootstrap_should_render_template', true)) {
return;
}
$template = $this->resolveTemplate();
if (! $template) {
return;
}
try {
$context = $this->contextBuilder->build();
$twig = TwigService::getInstance();
echo $twig->render($template, $context);
exit;
} catch (\Throwable $e) {
// Log the error and fall back to FSE rendering.
error_log('WP Bootstrap Twig Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
if (defined('WP_DEBUG') && WP_DEBUG) {
wp_die(
'<h1>Template Rendering Error</h1>'
. '<p><strong>' . esc_html($e->getMessage()) . '</strong></p>'
. '<p>' . esc_html($e->getFile()) . ':' . esc_html($e->getLine()) . '</p>'
. '<pre>' . esc_html($e->getTraceAsString()) . '</pre>',
'Template Error',
['response' => 500]
);
}
}
}
/**
* Determine which Twig template to render based on WordPress conditionals.
*/
private function resolveTemplate(): ?string
{
if (is_404()) {
return 'pages/404.html.twig';
}
if (is_search()) {
return 'pages/search.html.twig';
}
if (is_singular('post')) {
$slug = get_page_template_slug();
return match ($slug) {
'page-full-width' => 'pages/single.html.twig',
default => 'pages/single-sidebar.html.twig',
};
}
if (is_page()) {
$slug = get_page_template_slug();
return match ($slug) {
'page-landing' => 'pages/landing.html.twig',
'page-full-width' => 'pages/full-width.html.twig',
'page-hero' => 'pages/hero.html.twig',
'page-sidebar' => 'pages/page-sidebar.html.twig',
default => 'pages/page.html.twig',
};
}
if (is_archive()) {
return 'pages/archive.html.twig';
}
if (is_home()) {
return 'pages/index.html.twig';
}
// Fallback.
return 'pages/index.html.twig';
}
}

View File

@@ -11,6 +11,7 @@ namespace WPBootstrap\Twig;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;
use Twig\TwigFilter;
class TwigService
{
@@ -24,12 +25,14 @@ class TwigService
$loader = new FilesystemLoader($viewsDir);
$this->twig = new Environment($loader, [
'cache' => WP_DEBUG ? false : $cacheDir,
'debug' => WP_DEBUG,
'auto_reload' => true,
'cache' => WP_DEBUG ? false : $cacheDir,
'debug' => WP_DEBUG,
'auto_reload' => WP_DEBUG,
]);
$this->registerWordPressFunctions();
$this->registerWordPressGlobals();
$this->registerFilters();
}
public static function getInstance(): self
@@ -57,6 +60,7 @@ class TwigService
private function registerWordPressFunctions(): void
{
// Translation functions.
$this->twig->addFunction(new TwigFunction('__', function (string $text, string $domain = 'wp-bootstrap'): string {
return __($text, $domain);
}));
@@ -65,8 +69,101 @@ class TwigService
_e($text, $domain);
}));
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html'));
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr'));
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url'));
$this->twig->addFunction(new TwigFunction('_n', function (string $single, string $plural, int $number, string $domain = 'wp-bootstrap'): string {
return _n($single, $plural, $number, $domain);
}));
// Escaping functions — marked is_safe so Twig does not double-escape their output.
// These functions already return HTML-safe strings; without is_safe, enabling
// Twig autoescape would double-encode the result (e.g. &amp; → &amp;amp;).
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html', ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr', ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url', ['is_safe' => ['html']]));
// WordPress head/footer output (captured via output buffering).
$this->twig->addFunction(new TwigFunction('wp_head', function (): string {
ob_start();
wp_head();
return ob_get_clean();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('wp_footer', function (): string {
ob_start();
wp_footer();
return ob_get_clean();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('wp_body_open', function (): string {
ob_start();
wp_body_open();
return ob_get_clean();
}, ['is_safe' => ['html']]));
// HTML attribute helpers.
$this->twig->addFunction(new TwigFunction('language_attributes', function (): string {
ob_start();
language_attributes();
return ob_get_clean();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('body_class', function (string $extra = ''): string {
ob_start();
body_class($extra);
return ob_get_clean();
}, ['is_safe' => ['html']]));
// URL and info helpers.
$this->twig->addFunction(new TwigFunction('home_url', function (string $path = '/'): string {
return home_url($path);
}));
$this->twig->addFunction(new TwigFunction('get_template_directory_uri', function (): string {
return get_template_directory_uri();
}));
$this->twig->addFunction(new TwigFunction('get_bloginfo', function (string $show): string {
return get_bloginfo($show);
}));
$this->twig->addFunction(new TwigFunction('get_search_query', function (): string {
return get_search_query();
}));
// Content filtering.
$this->twig->addFunction(new TwigFunction('wp_kses_post', function (string $content): string {
return wp_kses_post($content);
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('do_shortcode', function (string $content): string {
return do_shortcode($content);
}, ['is_safe' => ['html']]));
// Formatting.
$this->twig->addFunction(new TwigFunction('number_format_i18n', function (float $number, int $decimals = 0): string {
return number_format_i18n($number, $decimals);
}));
// Block template parts (allows FSE Template Editor changes to take effect).
$this->twig->addFunction(new TwigFunction('block_template_part', function (string $part): string {
ob_start();
block_template_part($part);
return ob_get_clean();
}, ['is_safe' => ['html']]));
}
private function registerWordPressGlobals(): void
{
$this->twig->addGlobal('site_name', get_bloginfo('name'));
$this->twig->addGlobal('site_description', get_bloginfo('description'));
$this->twig->addGlobal('site_url', home_url('/'));
$this->twig->addGlobal('theme_uri', get_template_directory_uri());
$this->twig->addGlobal('charset', get_bloginfo('charset'));
$this->twig->addGlobal('current_year', date('Y'));
}
private function registerFilters(): void
{
$this->twig->addFilter(new TwigFilter('wpautop', 'wpautop', ['is_safe' => ['html']]));
$this->twig->addFilter(new TwigFilter('wp_kses_post', 'wp_kses_post', ['is_safe' => ['html']]));
}
}

View File

@@ -1,572 +0,0 @@
# German (Switzerland) translation for WP Bootstrap.
# Copyright (C) 2026 Marco Graetsch
# This file is distributed under the same license as the WP Bootstrap theme.
#
msgid ""
msgstr ""
"Project-Id-Version: WP Bootstrap 0.1.0\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-bootstrap/issues\n"
"POT-Creation-Date: 2026-02-08 00:00+0000\n"
"PO-Revision-Date: 2026-02-08 00:00+0000\n"
"Last-Translator: Marco Graetsch <magdev3.0@gmail.com>\n"
"Language-Team: German (Switzerland)\n"
"Language: de_CH\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Domain: wp-bootstrap\n"
#. Theme Name of the theme
#: patterns/footer.php
msgid "WP Bootstrap"
msgstr "WP Bootstrap"
#. Description of the theme
msgid "A modern WordPress Block Theme built from scratch with Bootstrap 5. Features responsive design, dark mode support, and full compatibility with the WordPress Site Editor."
msgstr "Ein modernes WordPress Block-Theme, erstellt mit Bootstrap 5. Mit responsivem Design, Darkmode-Unterstützung und voller Kompatibilität mit dem WordPress Site-Editor."
#: patterns/footer.php
msgid "Powered by %s"
msgstr "Betrieben mit %s"
#: patterns/footer.php
msgid "https://wordpress.org"
msgstr "https://de.wordpress.org"
#: patterns/hidden-404.php
msgid "Page not found"
msgstr "Seite nicht gefunden"
#: patterns/hidden-404.php
msgid "The page you are looking for does not exist, or it has been moved. Please try searching using the form below."
msgstr "Die gesuchte Seite existiert nicht oder wurde verschoben. Bitte verwenden Sie das untenstehende Suchformular."
#: patterns/hidden-search.php
#: patterns/hidden-sidebar.php
msgid "Search"
msgstr "Suchen"
#: patterns/hidden-search.php
#: patterns/hidden-sidebar.php
msgid "Search..."
msgstr "Suchen..."
#: patterns/hidden-blog-heading.php
msgid "Blog"
msgstr "Blog"
#: patterns/comments.php
msgid "Comments"
msgstr "Kommentare"
#: patterns/post-navigation.php
msgid "Previous"
msgstr "Zurück"
#: patterns/post-navigation.php
msgid "Next"
msgstr "Weiter"
#: patterns/more-posts.php
msgid "More posts"
msgstr "Weitere Beiträge"
#: patterns/template-query-loop.php
msgid "Read more"
msgstr "Weiterlesen"
#: patterns/template-query-loop.php
msgid "No posts were found."
msgstr "Es wurden keine Beiträge gefunden."
#: patterns/hero-cover.php
msgid "Build something amazing"
msgstr "Erstellen Sie etwas Grossartiges"
#: patterns/hero-cover.php
msgid "Create modern, responsive websites with the power of Bootstrap 5 and the WordPress Site Editor."
msgstr "Erstellen Sie moderne, responsive Websites mit der Leistung von Bootstrap 5 und dem WordPress Site-Editor."
#: patterns/hero-cover.php
#: patterns/hero-centered.php
#: patterns/pricing-3-col.php
msgid "Get Started"
msgstr "Jetzt starten"
#: patterns/hero-split.php
msgid "Modern design meets powerful features"
msgstr "Modernes Design trifft auf leistungsstarke Funktionen"
#: patterns/hero-split.php
msgid "A WordPress theme built from the ground up with Bootstrap 5 for a seamless editing and browsing experience."
msgstr "Ein WordPress-Theme, von Grund auf mit Bootstrap 5 erstellt, für ein nahtloses Bearbeitungs- und Browsing-Erlebnis."
#: patterns/hero-split.php
#: patterns/hero-centered.php
#: patterns/text-about.php
msgid "Learn More"
msgstr "Mehr erfahren"
#: patterns/hero-split.php
msgid "View Demo"
msgstr "Demo ansehen"
#: patterns/hero-split.php
msgid "Hero image"
msgstr "Heldenbild"
#: patterns/hero-centered.php
msgid "Welcome to your new website"
msgstr "Willkommen auf Ihrer neuen Website"
#: patterns/hero-centered.php
msgid "Start building beautiful, responsive pages with the full power of Bootstrap 5 and the WordPress block editor."
msgstr "Beginnen Sie mit dem Erstellen schöner, responsiver Seiten mit der vollen Leistung von Bootstrap 5 und dem WordPress Block-Editor."
#: patterns/features-3-col.php
#: functions.php
msgid "Features"
msgstr "Funktionen"
#: patterns/features-3-col.php
msgid "Everything you need to build a modern website."
msgstr "Alles, was Sie für eine moderne Website benötigen."
#: patterns/features-3-col.php
msgid "Responsive Design"
msgstr "Responsives Design"
#: patterns/features-3-col.php
msgid "Your website looks great on every device, from mobile phones to large desktop screens."
msgstr "Ihre Website sieht auf jedem Gerät grossartig aus, vom Mobiltelefon bis zum grossen Desktop-Bildschirm."
#: patterns/features-3-col.php
msgid "Easy Customization"
msgstr "Einfache Anpassung"
#: patterns/features-3-col.php
msgid "Customize colors, fonts, and layouts using the WordPress Site Editor with no code required."
msgstr "Passen Sie Farben, Schriftarten und Layouts mit dem WordPress Site-Editor an, ganz ohne Programmierung."
#: patterns/features-3-col.php
msgid "Performance First"
msgstr "Leistung zuerst"
#: patterns/features-3-col.php
msgid "Built with speed in mind. Optimized assets and clean code for lightning-fast page loads."
msgstr "Entwickelt mit Fokus auf Geschwindigkeit. Optimierte Ressourcen und sauberer Code für blitzschnelle Seitenladezeiten."
#: patterns/features-icon-list.php
msgid "Why choose us"
msgstr "Warum Sie uns wählen sollten"
#: patterns/features-icon-list.php
msgid "Bootstrap 5 Framework"
msgstr "Bootstrap 5 Framework"
#: patterns/features-icon-list.php
msgid "Built on the most popular CSS framework. Leverage a proven, well-documented design system."
msgstr "Basierend auf dem beliebtesten CSS-Framework. Nutzen Sie ein bewährtes, gut dokumentiertes Design-System."
#: patterns/features-icon-list.php
msgid "Full Site Editing"
msgstr "Vollständige Website-Bearbeitung"
#: patterns/features-icon-list.php
msgid "Edit every part of your site visually. Headers, footers, templates, and content are all customizable."
msgstr "Bearbeiten Sie jeden Teil Ihrer Website visuell. Kopfzeilen, Fusszeilen, Vorlagen und Inhalte sind vollständig anpassbar."
#: patterns/features-icon-list.php
msgid "Dark Mode Support"
msgstr "Darkmode-Unterstützung"
#: patterns/features-icon-list.php
msgid "Built-in dark mode toggle that respects user preferences and persists across visits."
msgstr "Integrierter Darkmode-Schalter, der Benutzereinstellungen respektiert und über Besuche hinweg beibehalten wird."
#: patterns/features-2-col-offset.php
msgid "Feature illustration"
msgstr "Funktionsillustration"
#: patterns/features-2-col-offset.php
msgid "Designed for modern workflows"
msgstr "Entwickelt für moderne Arbeitsabläufe"
#: patterns/features-2-col-offset.php
msgid "Streamline your development process with a theme that works the way you do."
msgstr "Optimieren Sie Ihren Entwicklungsprozess mit einem Theme, das so arbeitet wie Sie."
#: patterns/features-2-col-offset.php
msgid "Block Patterns"
msgstr "Block-Vorlagen"
#: patterns/features-2-col-offset.php
msgid "Pre-built patterns for common page sections. Drop them in and customize to fit your needs."
msgstr "Vorgefertigte Vorlagen für gängige Seitenabschnitte. Fügen Sie diese ein und passen Sie sie an Ihre Bedürfnisse an."
#: patterns/features-2-col-offset.php
msgid "Style Variations"
msgstr "Stilvariationen"
#: patterns/features-2-col-offset.php
msgid "Switch between color schemes with a single click. Choose from multiple professionally designed palettes."
msgstr "Wechseln Sie mit einem Klick zwischen Farbschemata. Wählen Sie aus mehreren professionell gestalteten Paletten."
#: patterns/cta-banner.php
msgid "Ready to get started?"
msgstr "Bereit loszulegen?"
#: patterns/cta-banner.php
msgid "Start building your website today with our powerful and flexible theme."
msgstr "Beginnen Sie noch heute mit dem Aufbau Ihrer Website mit unserem leistungsstarken und flexiblen Theme."
#: patterns/cta-banner.php
msgid "Start Now"
msgstr "Jetzt beginnen"
#: patterns/cta-newsletter.php
msgid "Stay in the loop"
msgstr "Bleiben Sie auf dem Laufenden"
#: patterns/cta-newsletter.php
msgid "Subscribe to our newsletter for updates, tips, and exclusive content."
msgstr "Abonnieren Sie unseren Newsletter für Aktualisierungen, Tipps und exklusive Inhalte."
#: patterns/cta-newsletter.php
msgid "Enter your email address"
msgstr "Geben Sie Ihre E-Mail-Adresse ein"
#: patterns/cta-newsletter.php
msgid "Subscribe"
msgstr "Abonnieren"
#: patterns/testimonials-2-col.php
msgid "What our clients say"
msgstr "Was unsere Kunden sagen"
#: patterns/testimonials-2-col.php
msgid "This theme completely transformed our website. The Bootstrap integration makes it incredibly easy to create professional-looking pages without any custom code."
msgstr "Dieses Theme hat unsere Website vollständig transformiert. Die Bootstrap-Integration macht es unglaublich einfach, professionell aussehende Seiten ohne individuellen Code zu erstellen."
#: patterns/testimonials-2-col.php
msgid "Jane Doe, Web Designer"
msgstr "Jane Doe, Webdesignerin"
#: patterns/testimonials-2-col.php
msgid "The dark mode support and style variations give us the flexibility we need. Our clients love being able to switch between color schemes effortlessly."
msgstr "Die Darkmode-Unterstützung und Stilvariationen geben uns die Flexibilität, die wir benötigen. Unsere Kunden schätzen es, mühelos zwischen Farbschemata wechseln zu können."
#: patterns/testimonials-2-col.php
msgid "John Smith, Developer"
msgstr "John Smith, Entwickler"
#: patterns/testimonials-centered.php
msgid "The best WordPress theme we have ever used. Clean code, beautiful design, and incredible flexibility."
msgstr "Das beste WordPress-Theme, das wir je verwendet haben. Sauberer Code, schönes Design und unglaubliche Flexibilität."
#: patterns/testimonials-centered.php
msgid "Alex Johnson, Creative Director"
msgstr "Alex Johnson, Kreativdirektor"
#: patterns/pricing-3-col.php
#: functions.php
msgid "Pricing"
msgstr "Preise"
#: patterns/pricing-3-col.php
msgid "Choose the plan that works best for you."
msgstr "Wählen Sie den Plan, der am besten zu Ihnen passt."
#: patterns/pricing-3-col.php
msgid "Basic"
msgstr "Basis"
#: patterns/pricing-3-col.php
msgid "Free"
msgstr "Kostenlos"
#: patterns/pricing-3-col.php
msgid "1 Website"
msgstr "1 Website"
#: patterns/pricing-3-col.php
msgid "Community Support"
msgstr "Community-Support"
#: patterns/pricing-3-col.php
msgid "Core Features"
msgstr "Kernfunktionen"
#: patterns/pricing-3-col.php
msgid "Professional"
msgstr "Professionell"
#: patterns/pricing-3-col.php
msgid "$49"
msgstr "49 $"
#: patterns/pricing-3-col.php
msgid "5 Websites"
msgstr "5 Websites"
#: patterns/pricing-3-col.php
msgid "Priority Support"
msgstr "Vorrangiger Support"
#: patterns/pricing-3-col.php
msgid "All Features"
msgstr "Alle Funktionen"
#: patterns/pricing-3-col.php
msgid "Enterprise"
msgstr "Unternehmen"
#: patterns/pricing-3-col.php
msgid "$199"
msgstr "199 $"
#: patterns/pricing-3-col.php
msgid "Unlimited Websites"
msgstr "Unbegrenzte Websites"
#: patterns/pricing-3-col.php
msgid "Dedicated Support"
msgstr "Persönlicher Support"
#: patterns/pricing-3-col.php
msgid "Custom Development"
msgstr "Individuelle Entwicklung"
#: patterns/pricing-3-col.php
msgid "Contact Us"
msgstr "Kontaktieren Sie uns"
#: patterns/contact-info.php
msgid "Get in touch"
msgstr "Kontakt aufnehmen"
#: patterns/contact-info.php
msgid "We would love to hear from you. Reach out through any of the channels below."
msgstr "Wir freuen uns von Ihnen zu hören. Kontaktieren Sie uns über einen der folgenden Kanäle."
#: patterns/contact-info.php
msgid "Address"
msgstr "Adresse"
#: patterns/contact-info.php
msgid "123 Example Street"
msgstr "Beispielstrasse 123"
#: patterns/contact-info.php
msgid "8000 Zurich, Switzerland"
msgstr "8000 Zürich, Schweiz"
#: patterns/contact-info.php
msgid "Phone"
msgstr "Telefon"
#: patterns/contact-info.php
msgid "+41 44 123 45 67"
msgstr "+41 44 123 45 67"
#: patterns/contact-info.php
#: patterns/cta-newsletter.php
msgid "Email"
msgstr "E-Mail"
#: patterns/contact-info.php
msgid "info@example.com"
msgstr "info@example.com"
#: patterns/text-faq.php
msgid "Frequently Asked Questions"
msgstr "Häufig gestellte Fragen"
#: patterns/text-faq.php
msgid "How do I install the theme?"
msgstr "Wie installiere ich das Theme?"
#: patterns/text-faq.php
msgid "Download the ZIP file from the releases page, then upload it via WordPress Admin > Appearance > Themes > Add New > Upload Theme."
msgstr "Laden Sie die ZIP-Datei von der Release-Seite herunter und laden Sie sie über WordPress-Admin > Design > Themes > Neu hinzufügen > Theme hochladen hoch."
#: patterns/text-faq.php
msgid "Does it work with the Site Editor?"
msgstr "Funktioniert es mit dem Site-Editor?"
#: patterns/text-faq.php
msgid "Yes, this is a Full Site Editing block theme. You can customize templates, headers, footers, and all block patterns using the WordPress Site Editor."
msgstr "Ja, dies ist ein Full-Site-Editing-Block-Theme. Sie können Vorlagen, Kopfzeilen, Fusszeilen und alle Block-Vorlagen mit dem WordPress Site-Editor anpassen."
#: patterns/text-faq.php
msgid "Can I use my own fonts?"
msgstr "Kann ich eigene Schriftarten verwenden?"
#: patterns/text-faq.php
msgid "The theme comes with Inter, Lora, and system font stacks. You can add custom fonts through the Site Editor or by modifying theme.json."
msgstr "Das Theme wird mit Inter, Lora und System-Schriftarten geliefert. Sie können eigene Schriftarten über den Site-Editor oder durch Anpassung der theme.json hinzufügen."
#: patterns/text-faq.php
msgid "Is dark mode supported?"
msgstr "Wird der Darkmode unterstützt?"
#: patterns/text-faq.php
msgid "Yes, the theme includes a dark mode toggle that uses Bootstrap 5.3 built-in dark mode. It respects system preferences and remembers your choice."
msgstr "Ja, das Theme enthält einen Darkmode-Schalter, der den integrierten Darkmode von Bootstrap 5.3 verwendet. Er respektiert Systemeinstellungen und merkt sich Ihre Wahl."
#: patterns/text-about.php
msgid "About us"
msgstr "Über uns"
#: patterns/text-about.php
msgid "We are passionate about creating tools that empower people to build beautiful websites. Our theme combines the reliability of Bootstrap with the flexibility of WordPress."
msgstr "Wir sind leidenschaftlich daran interessiert, Werkzeuge zu schaffen, die Menschen befähigen, schöne Websites zu erstellen. Unser Theme vereint die Zuverlässigkeit von Bootstrap mit der Flexibilität von WordPress."
#: patterns/text-about.php
msgid "With years of experience in web development and design, we understand what it takes to create a theme that is both powerful and easy to use."
msgstr "Mit jahrelanger Erfahrung in Webentwicklung und Design verstehen wir, was nötig ist, um ein Theme zu erstellen, das sowohl leistungsstark als auch einfach zu bedienen ist."
#: patterns/text-about.php
msgid "About us image"
msgstr "Über-uns-Bild"
#: patterns/hidden-sidebar.php
msgid "Recent Posts"
msgstr "Neueste Beiträge"
#: patterns/hidden-sidebar.php
msgid "Tags"
msgstr "Schlagwörter"
#: patterns/dark-mode-toggle.php
msgid "Switch to dark mode"
msgstr "Zum Darkmode wechseln"
#: patterns/dark-mode-toggle.php
msgid "Switch to light mode"
msgstr "Zum hellen Modus wechseln"
#: functions.php
msgid "Pages"
msgstr "Seiten"
#: functions.php
msgid "A collection of full page layouts."
msgstr "Eine Sammlung von ganzseitigen Layouts."
#: functions.php
msgid "Hero Sections"
msgstr "Heldenabschnitte"
#: functions.php
msgid "Large hero and banner sections."
msgstr "Grosse Helden- und Banner-Abschnitte."
#: functions.php
msgid "Call to Action"
msgstr "Handlungsaufforderung"
#: functions.php
msgid "Call to action sections."
msgstr "Handlungsaufforderungs-Abschnitte."
#: functions.php
msgid "Feature and service showcase sections."
msgstr "Funktions- und Service-Präsentationsabschnitte."
#: functions.php
msgid "Testimonials"
msgstr "Referenzen"
#: functions.php
msgid "Testimonial and review sections."
msgstr "Referenz- und Bewertungsabschnitte."
#: functions.php
msgid "Pricing table sections."
msgstr "Preistabellen-Abschnitte."
#: functions.php
msgid "Contact"
msgstr "Kontakt"
#: functions.php
msgid "Contact information sections."
msgstr "Kontaktinformations-Abschnitte."
#: functions.php
msgid "Text & Content"
msgstr "Text & Inhalt"
#: functions.php
msgid "Text-focused content sections."
msgstr "Textorientierte Inhaltsabschnitte."
#: functions.php
msgid "Checkmark"
msgstr "Häkchen"
#: functions.php
msgid "Unstyled"
msgstr "Ohne Stil"
#: functions.php
msgid "Card"
msgstr "Karte"
#: functions.php
msgid "Card with Shadow"
msgstr "Karte mit Schatten"
#: functions.php
msgid "Alert - Info"
msgstr "Hinweis - Info"
#: functions.php
msgid "Alert - Success"
msgstr "Hinweis - Erfolg"
#: functions.php
msgid "Alert - Warning"
msgstr "Hinweis - Warnung"
#: functions.php
msgid "Alert - Danger"
msgstr "Hinweis - Gefahr"
#: functions.php
msgid "Striped Rows"
msgstr "Gestreifte Zeilen"
#: functions.php
msgid "Hover Rows"
msgstr "Hervorgehobene Zeilen"
#: functions.php
msgid "Bordered"
msgstr "Mit Rahmen"
#: functions.php
msgid "Accent Border"
msgstr "Akzentrahmen"
#: functions.php
msgid "Shadow"
msgstr "Schatten"
#: functions.php
msgid "Large Rounded"
msgstr "Gross abgerundet"
#: functions.php
msgid "Large"
msgstr "Gross"
#: functions.php
msgid "Small"
msgstr "Klein"
#: functions.php
msgid "Wide"
msgstr "Breit"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

23
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "wp-bootstrap",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wp-bootstrap",
"version": "0.1.0",
"version": "1.0.0",
"license": "GPL-2.0-or-later",
"dependencies": {
"@popperjs/core": "^2.11",
"bootstrap": "^5.3"
"bootstrap": "^5.3",
"bootstrap-icons": "^1.13.1"
},
"devDependencies": {
"autoprefixer": "^10.4",
@@ -259,6 +260,22 @@
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "wp-bootstrap",
"version": "0.1.0",
"version": "1.0.1",
"description": "WordPress Theme built with Bootstrap 5",
"author": "Marco Graetsch <magdev3.0@gmail.com>",
"license": "GPL-2.0-or-later",
@@ -13,24 +13,29 @@
"node": ">=20.0.0"
},
"dependencies": {
"@popperjs/core": "^2.11",
"bootstrap": "^5.3",
"@popperjs/core": "^2.11"
"bootstrap-icons": "^1.13.1"
},
"devDependencies": {
"sass": "^1.97",
"autoprefixer": "^10.4",
"copyfiles": "^2.4",
"cssnano": "^7.1",
"postcss": "^8.5",
"postcss-cli": "^11",
"autoprefixer": "^10.4",
"cssnano": "^7.1",
"copyfiles": "^2.4"
"sass": "^1.97"
},
"scripts": {
"scss": "sass src/scss/style.scss:assets/css/style.css src/scss/editor-style.scss:assets/css/editor-style.css --load-path=node_modules",
"scss:rtl": "sass src/scss/rtl.scss:assets/css/rtl.css --style=compressed --no-source-map",
"scss:watch": "sass --watch src/scss/style.scss:assets/css/style.css src/scss/editor-style.scss:assets/css/editor-style.css --load-path=node_modules",
"postcss": "postcss assets/css/style.css --use autoprefixer cssnano -o assets/css/style.min.css --no-map",
"copy:js": "copyfiles -f node_modules/bootstrap/dist/js/bootstrap.bundle.min.js node_modules/bootstrap/dist/js/bootstrap.bundle.min.js.map assets/js/",
"copy:theme-js": "copyfiles -f src/js/dark-mode.js assets/js/",
"build": "npm run copy:js && npm run copy:theme-js && npm run scss && npm run postcss",
"copy:icons": "copyfiles -f node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 assets/fonts/",
"test": "composer exec -- phpunit",
"prebuild": "npm run test",
"build": "npm run copy:js && npm run copy:theme-js && npm run copy:icons && npm run scss && npm run scss:rtl && npm run postcss",
"watch": "npm run copy:js && npm run scss:watch",
"dev": "npm run watch"
}

View File

@@ -0,0 +1 @@
<!-- wp:pattern {"slug":"wp-bootstrap/footer-columns"} /-->

View File

@@ -0,0 +1 @@
<!-- wp:pattern {"slug":"wp-bootstrap/footer-minimal"} /-->

View File

@@ -0,0 +1 @@
<!-- wp:pattern {"slug":"wp-bootstrap/header-centered"} /-->

View File

@@ -0,0 +1 @@
<!-- wp:pattern {"slug":"wp-bootstrap/header-transparent"} /-->

View File

@@ -0,0 +1,38 @@
<?php
/**
* Title: Accordion
* Slug: wp-bootstrap/component-accordion
* Categories: wp-bootstrap-components, text
* Description: An accordion section using details blocks with summary and content paragraphs.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"layout":{"type":"constrained","contentSize":"720px"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:heading {"textAlign":"center","fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-text-align-center has-xx-large-font-size"><?php esc_html_e( 'Accordion', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Click on each item to expand and reveal its content.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:details -->
<details class="wp-block-details"><summary><?php esc_html_e( 'Accordion Item One', 'wp-bootstrap' ); ?></summary><!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'This is the content for the first accordion item. You can add any blocks inside this details element to create rich, expandable content sections.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></details>
<!-- /wp:details -->
<!-- wp:details -->
<details class="wp-block-details"><summary><?php esc_html_e( 'Accordion Item Two', 'wp-bootstrap' ); ?></summary><!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'This is the content for the second accordion item. Details blocks are a native HTML element that provide toggle functionality without JavaScript.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></details>
<!-- /wp:details -->
<!-- wp:details -->
<details class="wp-block-details"><summary><?php esc_html_e( 'Accordion Item Three', 'wp-bootstrap' ); ?></summary><!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'This is the content for the third accordion item. Use accordions to organize frequently asked questions, feature lists, or any content that benefits from progressive disclosure.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></details>
<!-- /wp:details --></div>
<!-- /wp:group -->

View File

@@ -0,0 +1,50 @@
<?php
/**
* Title: Card Group
* Slug: wp-bootstrap/component-card-group
* Categories: wp-bootstrap-components
* Description: A three-column card group with shadow-styled cards, each containing a heading and paragraph.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|40"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:group {"className":"is-style-card-shadow","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group is-style-card-shadow" style="padding-top:var(--wp--preset--spacing--50);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--50);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading"><?php esc_html_e( 'Card One', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add a short description for this card. Cards are a great way to organize and present related content.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:group {"className":"is-style-card-shadow","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group is-style-card-shadow" style="padding-top:var(--wp--preset--spacing--50);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--50);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading"><?php esc_html_e( 'Card Two', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add a short description for this card. Cards are a great way to organize and present related content.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:group {"className":"is-style-card-shadow","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group is-style-card-shadow" style="padding-top:var(--wp--preset--spacing--50);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--50);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading"><?php esc_html_e( 'Card Three', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add a short description for this card. Cards are a great way to organize and present related content.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->

View File

@@ -0,0 +1,85 @@
<?php
/**
* Title: Footer - Multi-Column
* Slug: wp-bootstrap/footer-columns
* Categories: footer
* Block Types: core/template-part/footer
* Description: Multi-column footer with site info, navigation, and credits.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|50"}}},"backgroundColor":"dark","textColor":"base","layout":{"type":"constrained"}} -->
<div class="wp-block-group has-base-color has-dark-background-color has-text-color has-background" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--50)">
<!-- wp:columns {"align":"wide"} -->
<div class="wp-block-columns alignwide">
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:site-title {"level":3} /-->
<!-- wp:site-tagline {"fontSize":"small"} /-->
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size"><?php esc_html_e( 'A modern WordPress theme built with Bootstrap 5.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"level":4,"fontSize":"medium"} -->
<h4 class="wp-block-heading has-medium-font-size"><?php esc_html_e( 'Navigation', 'wp-bootstrap' ); ?></h4>
<!-- /wp:heading -->
<!-- wp:navigation {"overlayMenu":"never","layout":{"type":"flex","orientation":"vertical"},"style":{"spacing":{"blockGap":"var:preset|spacing|10"}}} /-->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"level":4,"fontSize":"medium"} -->
<h4 class="wp-block-heading has-medium-font-size"><?php esc_html_e( 'About', 'wp-bootstrap' ); ?></h4>
<!-- /wp:heading -->
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size"><?php esc_html_e( 'This theme is proudly built with Bootstrap 5 and WordPress Full Site Editing.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
<!-- wp:spacer {"height":"var:preset|spacing|40"} -->
<div style="height:var(--wp--preset--spacing--40)" aria-hidden="true" class="wp-block-spacer"></div>
<!-- /wp:spacer -->
<!-- wp:separator {"align":"wide","className":"is-style-separator-wide"} -->
<hr class="wp-block-separator alignwide has-alpha-channel-opacity is-style-separator-wide"/>
<!-- /wp:separator -->
<!-- wp:group {"align":"wide","layout":{"type":"flex","flexWrap":"wrap","justifyContent":"space-between"}} -->
<div class="wp-block-group alignwide">
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size">
<?php
printf(
/* translators: Copyright notice. %1$s: Year, %2$s: Site title. */
esc_html__( '&copy; %1$s %2$s', 'wp-bootstrap' ),
esc_html( gmdate( 'Y' ) ),
esc_html( get_bloginfo( 'name' ) )
);
?>
</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size">
<?php
printf(
/* translators: Powered by WordPress. %s: WordPress link. */
esc_html__( 'Powered by %s', 'wp-bootstrap' ),
'<a href="' . esc_url( __( 'https://wordpress.org', 'wp-bootstrap' ) ) . '" rel="nofollow">WordPress</a>'
);
?>
</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->

View File

@@ -0,0 +1,35 @@
<?php
/**
* Title: Footer - Minimal
* Slug: wp-bootstrap/footer-minimal
* Categories: footer
* Block Types: core/template-part/footer
* Description: Minimal single-line footer with copyright.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var:preset|spacing|40","bottom":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group" style="padding-top:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--40)">
<!-- wp:separator {"className":"is-style-separator-wide","style":{"spacing":{"margin":{"bottom":"var:preset|spacing|40"}}}} -->
<hr class="wp-block-separator has-alpha-channel-opacity is-style-separator-wide" style="margin-bottom:var(--wp--preset--spacing--40)"/>
<!-- /wp:separator -->
<!-- wp:group {"align":"wide","layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"center"}} -->
<div class="wp-block-group alignwide">
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size">
<?php
printf(
/* translators: Copyright notice. %1$s: Year, %2$s: Site title. */
esc_html__( '&copy; %1$s %2$s. All rights reserved.', 'wp-bootstrap' ),
esc_html( gmdate( 'Y' ) ),
esc_html( get_bloginfo( 'name' ) )
);
?>
</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->

View File

@@ -0,0 +1,32 @@
<?php
/**
* Title: Header - Centered
* Slug: wp-bootstrap/header-centered
* Categories: header
* Block Types: core/template-part/header
* Description: Centered header with site title above navigation.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","layout":{"type":"default"}} -->
<div class="wp-block-group alignfull">
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:group {"align":"wide","style":{"spacing":{"padding":{"top":"var:preset|spacing|40","bottom":"var:preset|spacing|20"}}},"layout":{"type":"flex","orientation":"vertical","justifyContent":"center"}} -->
<div class="wp-block-group alignwide" style="padding-top:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--20)">
<!-- wp:site-title {"level":0,"textAlign":"center"} /-->
<!-- wp:site-tagline {"textAlign":"center","fontSize":"small"} /-->
</div>
<!-- /wp:group -->
<!-- wp:group {"align":"wide","style":{"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|30"}}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"center"}} -->
<div class="wp-block-group alignwide" style="padding-top:var(--wp--preset--spacing--20);padding-bottom:var(--wp--preset--spacing--30)">
<!-- wp:navigation {"overlayBackgroundColor":"base","overlayTextColor":"contrast","layout":{"type":"flex","justifyContent":"center","flexWrap":"wrap"}} /-->
<!-- wp:pattern {"slug":"wp-bootstrap/dark-mode-toggle"} /-->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->

View File

@@ -0,0 +1,31 @@
<?php
/**
* Title: Header - Transparent
* Slug: wp-bootstrap/header-transparent
* Categories: header
* Block Types: core/template-part/header
* Description: Transparent header that overlays page content, ideal for hero sections.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"0","bottom":"0"}}},"layout":{"type":"default"}} -->
<div class="wp-block-group alignfull" style="padding-top:0;padding-bottom:0">
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:group {"align":"wide","style":{"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30"}}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"space-between"}} -->
<div class="wp-block-group alignwide" style="padding-top:var(--wp--preset--spacing--30);padding-bottom:var(--wp--preset--spacing--30)">
<!-- wp:site-title {"level":0,"style":{"elements":{"link":{"color":{"text":"var:preset|color|base"}}}}} /-->
<!-- wp:group {"style":{"spacing":{"blockGap":"var:preset|spacing|20"}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"right","verticalAlignment":"center"}} -->
<div class="wp-block-group">
<!-- wp:navigation {"overlayBackgroundColor":"base","overlayTextColor":"contrast","style":{"elements":{"link":{"color":{"text":"var:preset|color|base"}}}},"layout":{"type":"flex","justifyContent":"right","flexWrap":"wrap"}} /-->
<!-- wp:pattern {"slug":"wp-bootstrap/dark-mode-toggle"} /-->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->

34
patterns/layout-2-col.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
/**
* Title: Two Columns
* Slug: wp-bootstrap/layout-2-col
* Categories: wp-bootstrap-layout, columns
* Description: A two-column layout with equal width columns, each containing a heading and paragraph.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60)"><!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|50"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"level":3,"fontSize":"x-large"} -->
<h3 class="wp-block-heading has-x-large-font-size"><?php esc_html_e( 'Column One', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add your content here. This column takes up half the available width on larger screens and stacks on mobile devices.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"level":3,"fontSize":"x-large"} -->
<h3 class="wp-block-heading has-x-large-font-size"><?php esc_html_e( 'Column Two', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add your content here. This column takes up half the available width on larger screens and stacks on mobile devices.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->

44
patterns/layout-3-col.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
/**
* Title: Three Columns
* Slug: wp-bootstrap/layout-3-col
* Categories: wp-bootstrap-layout, columns
* Description: A three-column layout with equal width columns, each containing a heading and paragraph.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60)"><!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|50"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"level":3,"fontSize":"x-large"} -->
<h3 class="wp-block-heading has-x-large-font-size"><?php esc_html_e( 'Column One', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add your content here. This column takes up one third of the available width on larger screens.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"level":3,"fontSize":"x-large"} -->
<h3 class="wp-block-heading has-x-large-font-size"><?php esc_html_e( 'Column Two', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add your content here. This column takes up one third of the available width on larger screens.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"level":3,"fontSize":"x-large"} -->
<h3 class="wp-block-heading has-x-large-font-size"><?php esc_html_e( 'Column Three', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Add your content here. This column takes up one third of the available width on larger screens.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->

View File

@@ -0,0 +1,20 @@
<?php
/**
* Title: Bootstrap Container
* Slug: wp-bootstrap/layout-container
* Categories: wp-bootstrap-layout
* Description: A constrained container with padding, containing a heading and paragraph placeholder.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--60);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-xx-large-font-size"><?php esc_html_e( 'Heading goes here', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p><?php esc_html_e( 'This is a content container with constrained width and comfortable padding. Replace this text with your own content.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->

View File

@@ -0,0 +1,20 @@
<?php
/**
* Title: Full Width Section
* Slug: wp-bootstrap/layout-full-width-section
* Categories: wp-bootstrap-layout, banner
* Description: A full-width section with a primary background color, centered white heading and paragraph.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|80","bottom":"var:preset|spacing|80"}}},"backgroundColor":"primary","textColor":"base","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-base-color has-primary-background-color has-text-color has-background" style="padding-top:var(--wp--preset--spacing--80);padding-bottom:var(--wp--preset--spacing--80)"><!-- wp:heading {"textAlign":"center","fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-text-align-center has-xx-large-font-size"><?php esc_html_e( 'Full Width Section Heading', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","fontSize":"large"} -->
<p class="has-text-align-center has-large-font-size"><?php esc_html_e( 'This full-width section stands out with a colored background. Use it to highlight important content, announcements, or calls to action.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->

31
patterns/nav-dark.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
/**
* Title: Navigation - Dark
* Slug: wp-bootstrap/nav-dark
* Categories: wp-bootstrap-navigation, header
* Block Types: core/template-part/header
* Description: Dark-themed navigation header with dark background.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","backgroundColor":"dark","textColor":"base","layout":{"type":"default"}} -->
<div class="wp-block-group alignfull has-base-color has-dark-background-color has-text-color has-background">
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:group {"align":"wide","style":{"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30"}}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"space-between"}} -->
<div class="wp-block-group alignwide" style="padding-top:var(--wp--preset--spacing--30);padding-bottom:var(--wp--preset--spacing--30)">
<!-- wp:site-title {"level":0,"style":{"elements":{"link":{"color":{"text":"var:preset|color|base"}}}}} /-->
<!-- wp:group {"style":{"spacing":{"blockGap":"var:preset|spacing|20"}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"right","verticalAlignment":"center"}} -->
<div class="wp-block-group">
<!-- wp:navigation {"overlayBackgroundColor":"dark","overlayTextColor":"base","style":{"elements":{"link":{"color":{"text":"var:preset|color|base"}}}},"layout":{"type":"flex","justifyContent":"right","flexWrap":"wrap"}} /-->
<!-- wp:pattern {"slug":"wp-bootstrap/dark-mode-toggle"} /-->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->

View File

@@ -0,0 +1,31 @@
<?php
/**
* Title: Navigation - Offcanvas
* Slug: wp-bootstrap/nav-offcanvas
* Categories: wp-bootstrap-navigation, header
* Block Types: core/template-part/header
* Description: Header with offcanvas mobile navigation using Bootstrap offcanvas component.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","layout":{"type":"default"}} -->
<div class="wp-block-group alignfull">
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:group {"align":"wide","style":{"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30"}}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"space-between"}} -->
<div class="wp-block-group alignwide" style="padding-top:var(--wp--preset--spacing--30);padding-bottom:var(--wp--preset--spacing--30)">
<!-- wp:site-title {"level":0} /-->
<!-- wp:group {"style":{"spacing":{"blockGap":"var:preset|spacing|20"}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"right","verticalAlignment":"center"}} -->
<div class="wp-block-group">
<!-- wp:navigation {"overlayMenu":"always","overlayBackgroundColor":"base","overlayTextColor":"contrast","layout":{"type":"flex","justifyContent":"right","flexWrap":"wrap"}} /-->
<!-- wp:pattern {"slug":"wp-bootstrap/dark-mode-toggle"} /-->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->

92
patterns/page-about.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
/**
* Title: About Page
* Slug: wp-bootstrap/page-about
* Categories: wp-bootstrap_page
* Description: A full about page layout with hero, story section, and team members.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|80","bottom":"var:preset|spacing|80"}}},"backgroundColor":"primary","textColor":"base","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-base-color has-primary-background-color has-text-color has-background" style="padding-top:var(--wp--preset--spacing--80);padding-bottom:var(--wp--preset--spacing--80)"><!-- wp:heading {"textAlign":"center","level":1,"fontSize":"display"} -->
<h1 class="wp-block-heading has-text-align-center has-display-font-size"><?php esc_html_e( 'About Us', 'wp-bootstrap' ); ?></h1>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","fontSize":"large"} -->
<p class="has-text-align-center has-large-font-size"><?php esc_html_e( 'Learn more about who we are, what we do, and the people behind our mission.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|60"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column {"verticalAlignment":"center"} -->
<div class="wp-block-column is-vertically-aligned-center"><!-- wp:heading {"fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-xx-large-font-size"><?php esc_html_e( 'Our Story', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p><?php esc_html_e( 'Founded with a passion for innovation and excellence, our journey began with a simple idea: to create meaningful solutions that make a real difference. Over the years, we have grown from a small team into a dedicated group of professionals committed to delivering outstanding results.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"textColor":"secondary"} -->
<p class="has-secondary-color has-text-color"><?php esc_html_e( 'Today, we continue to push boundaries and challenge conventions. Our approach combines creative thinking with proven methodologies, ensuring that every project we undertake meets the highest standards of quality and craftsmanship.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column {"verticalAlignment":"center"} -->
<div class="wp-block-column is-vertically-aligned-center"><!-- wp:image {"sizeSlug":"large","style":{"border":{"radius":"0.5rem"}}} -->
<figure class="wp-block-image size-large" style="border-radius:0.5rem"><img src="" alt="<?php esc_attr_e( 'About us image', 'wp-bootstrap' ); ?>"/></figure>
<!-- /wp:image --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"backgroundColor":"light","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-light-background-color has-background" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:heading {"textAlign":"center","fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-text-align-center has-xx-large-font-size"><?php esc_html_e( 'Our Team', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Meet the people who make it all happen.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|40"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:group {"className":"is-style-card","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group is-style-card" style="padding-top:var(--wp--preset--spacing--50);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--50);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"textAlign":"center","level":3} -->
<h3 class="wp-block-heading has-text-align-center"><?php esc_html_e( 'Jane Doe', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Founder & CEO', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:group {"className":"is-style-card","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group is-style-card" style="padding-top:var(--wp--preset--spacing--50);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--50);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"textAlign":"center","level":3} -->
<h3 class="wp-block-heading has-text-align-center"><?php esc_html_e( 'John Smith', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Lead Developer', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:group {"className":"is-style-card","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50","left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group is-style-card" style="padding-top:var(--wp--preset--spacing--50);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--50);padding-left:var(--wp--preset--spacing--40)"><!-- wp:heading {"textAlign":"center","level":3} -->
<h3 class="wp-block-heading has-text-align-center"><?php esc_html_e( 'Emily Johnson', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Creative Director', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->

66
patterns/page-contact.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
/**
* Title: Contact Page
* Slug: wp-bootstrap/page-contact
* Categories: wp-bootstrap_page
* Description: A full contact page layout with hero, contact details, business hours, and spacer.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|80","bottom":"var:preset|spacing|80"}}},"backgroundColor":"primary","textColor":"base","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-base-color has-primary-background-color has-text-color has-background" style="padding-top:var(--wp--preset--spacing--80);padding-bottom:var(--wp--preset--spacing--80)"><!-- wp:heading {"textAlign":"center","level":1,"fontSize":"display"} -->
<h1 class="wp-block-heading has-text-align-center has-display-font-size"><?php esc_html_e( 'Contact Us', 'wp-bootstrap' ); ?></h1>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","fontSize":"large"} -->
<p class="has-text-align-center has-large-font-size"><?php esc_html_e( 'We would love to hear from you. Reach out to us anytime.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|60"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column {"verticalAlignment":"top"} -->
<div class="wp-block-column is-vertically-aligned-top"><!-- wp:heading {"fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-xx-large-font-size"><?php esc_html_e( 'Get in Touch', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p><strong><?php esc_html_e( 'Address', 'wp-bootstrap' ); ?></strong><br><?php esc_html_e( '123 Main Street, Suite 100, Anytown, ST 12345', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p><strong><?php esc_html_e( 'Email', 'wp-bootstrap' ); ?></strong><br><?php esc_html_e( 'info@example.com', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p><strong><?php esc_html_e( 'Phone', 'wp-bootstrap' ); ?></strong><br><?php esc_html_e( '+1 (555) 123-4567', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column {"verticalAlignment":"top"} -->
<div class="wp-block-column is-vertically-aligned-top"><!-- wp:heading {"fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-xx-large-font-size"><?php esc_html_e( 'Business Hours', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:list {"className":"is-style-list-unstyled","style":{"spacing":{"blockGap":"var:preset|spacing|20"}}} -->
<ul class="is-style-list-unstyled"><!-- wp:list-item -->
<li><strong><?php esc_html_e( 'Monday - Friday:', 'wp-bootstrap' ); ?></strong> <?php esc_html_e( '9:00 AM - 6:00 PM', 'wp-bootstrap' ); ?></li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li><strong><?php esc_html_e( 'Saturday:', 'wp-bootstrap' ); ?></strong> <?php esc_html_e( '10:00 AM - 4:00 PM', 'wp-bootstrap' ); ?></li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li><strong><?php esc_html_e( 'Sunday:', 'wp-bootstrap' ); ?></strong> <?php esc_html_e( 'Closed', 'wp-bootstrap' ); ?></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->
<!-- wp:spacer {"height":"var:preset|spacing|70"} -->
<div style="height:var(--wp--preset--spacing--70)" aria-hidden="true" class="wp-block-spacer"></div>
<!-- /wp:spacer -->

View File

@@ -0,0 +1,94 @@
<?php
/**
* Title: Services Page
* Slug: wp-bootstrap/page-services
* Categories: wp-bootstrap_page
* Description: A full services page layout with hero, service offerings, and call to action.
*
* @package WPBootstrap
* @since 0.2.0
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|80","bottom":"var:preset|spacing|80"}}},"backgroundColor":"dark","textColor":"base","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-base-color has-dark-background-color has-text-color has-background" style="padding-top:var(--wp--preset--spacing--80);padding-bottom:var(--wp--preset--spacing--80)"><!-- wp:heading {"textAlign":"center","level":1,"fontSize":"display"} -->
<h1 class="wp-block-heading has-text-align-center has-display-font-size"><?php esc_html_e( 'Our Services', 'wp-bootstrap' ); ?></h1>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","fontSize":"large"} -->
<p class="has-text-align-center has-large-font-size"><?php esc_html_e( 'Professional solutions tailored to your needs.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:heading {"textAlign":"center","fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-text-align-center has-xx-large-font-size"><?php esc_html_e( 'What We Offer', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'We provide a wide range of services to help your business grow and succeed.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|50"}}}} -->
<div class="wp-block-columns alignwide"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"textAlign":"center","level":3,"fontSize":"display"} -->
<h3 class="wp-block-heading has-text-align-center has-display-font-size"><?php esc_html_e( "\xF0\x9F\x8E\xA8", 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:heading {"textAlign":"center","level":3} -->
<h3 class="wp-block-heading has-text-align-center"><?php esc_html_e( 'Design', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Beautiful, user-centered designs that capture your brand identity and engage your audience across all platforms and devices.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"textAlign":"center","level":3,"fontSize":"display"} -->
<h3 class="wp-block-heading has-text-align-center has-display-font-size"><?php esc_html_e( "\xE2\x9A\x99\xEF\xB8\x8F", 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:heading {"textAlign":"center","level":3} -->
<h3 class="wp-block-heading has-text-align-center"><?php esc_html_e( 'Development', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Robust, scalable web applications built with modern technologies and best practices to ensure performance and reliability.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column"><!-- wp:heading {"textAlign":"center","level":3,"fontSize":"display"} -->
<h3 class="wp-block-heading has-text-align-center has-display-font-size"><?php esc_html_e( "\xF0\x9F\x93\x88", 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:heading {"textAlign":"center","level":3} -->
<h3 class="wp-block-heading has-text-align-center"><?php esc_html_e( 'Strategy', 'wp-bootstrap' ); ?></h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","textColor":"secondary"} -->
<p class="has-text-align-center has-secondary-color has-text-color"><?php esc_html_e( 'Data-driven strategies and consulting to help you achieve your business goals and stay ahead of the competition.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph --></div>
<!-- /wp:column --></div>
<!-- /wp:columns --></div>
<!-- /wp:group -->
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"backgroundColor":"primary","textColor":"base","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-base-color has-primary-background-color has-text-color has-background" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"><!-- wp:heading {"textAlign":"center","fontSize":"xx-large"} -->
<h2 class="wp-block-heading has-text-align-center has-xx-large-font-size"><?php esc_html_e( 'Get Started', 'wp-bootstrap' ); ?></h2>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","fontSize":"medium"} -->
<p class="has-text-align-center has-medium-font-size"><?php esc_html_e( 'Ready to take your project to the next level? Let us help you build something great.', 'wp-bootstrap' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons"><!-- wp:button {"backgroundColor":"base","textColor":"primary"} -->
<div class="wp-block-button"><a class="wp-block-button__link has-primary-color has-base-background-color has-text-color has-background wp-element-button"><?php esc_html_e( 'Contact Us', 'wp-bootstrap' ); ?></a></div>
<!-- /wp:button -->
<!-- wp:button {"className":"is-style-outline","style":{"elements":{"link":{"color":{"text":"var:preset|color|base"}}}},"textColor":"base"} -->
<div class="wp-block-button is-style-outline"><a class="wp-block-button__link has-base-color has-text-color has-link-color wp-element-button"><?php esc_html_e( 'View Portfolio', 'wp-bootstrap' ); ?></a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons --></div>
<!-- /wp:group -->

21
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>inc</directory>
</include>
</source>
</phpunit>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 284 KiB

View File

@@ -20,7 +20,9 @@
*/
function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
// Whitelist: only honour known-good values to prevent attribute injection
// from a tampered localStorage (e.g. XSS-written value by another script).
if (stored === 'dark' || stored === 'light') {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
@@ -61,6 +63,7 @@
var newTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, newTheme);
setTheme(newTheme);
announceTheme(newTheme);
});
});
});
@@ -71,4 +74,23 @@
setTheme(e.matches ? 'dark' : 'light');
}
});
/**
* Announce theme change to screen readers via a live region.
*
* @param {string} theme - 'dark' or 'light'
*/
function announceTheme(theme) {
var msg = theme === 'dark' ? 'Dark mode enabled' : 'Light mode enabled';
var el = document.getElementById('wp-bootstrap-theme-status');
if (!el) {
el = document.createElement('div');
el.id = 'wp-bootstrap-theme-status';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.className = 'visually-hidden';
document.body.appendChild(el);
}
el.textContent = msg;
}
})();

View File

@@ -25,6 +25,41 @@
}
}
// Transparent header variant
.header-transparent {
position: absolute;
width: 100%;
z-index: $zindex-fixed;
.navbar {
background: transparent !important;
}
}
// Force Bootstrap dark mode body colors past any WordPress global styles.
// WordPress may output body { background-color: ...; color: ...; } via
// global-styles that overrides Bootstrap's variable-based body styling.
html[data-bs-theme="dark"] body {
background-color: var(--bs-body-bg) !important;
color: var(--bs-body-color) !important;
}
// Dark mode for all form elements — catches plugin-generated controls
// that lack Bootstrap's .form-select / .form-control classes.
[data-bs-theme="dark"] {
select,
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="reset"]),
textarea {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
}
.offcanvas {
--bs-offcanvas-bg: var(--bs-body-bg);
}
}
// Dark mode overrides for block styles with hardcoded colors
[data-bs-theme="dark"] {
.is-style-alert-info {
@@ -57,3 +92,46 @@
background: var(--bs-body-bg);
}
}
// Skip link (accessibility)
.wp-bootstrap-skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: $zindex-fixed + 10;
padding: 0.5rem 1rem;
background: var(--bs-primary);
color: #fff;
text-decoration: none;
font-weight: 600;
&:focus {
top: 0;
}
}
// Post featured image
.post-thumbnail {
aspect-ratio: 16 / 9;
object-fit: cover;
width: 100%;
}
// Card thumbnail
.card-thumbnail {
aspect-ratio: 3 / 2;
object-fit: cover;
}
// Sidebar heading
.sidebar-heading {
letter-spacing: 1.6px;
}
// Hero overlay
.hero-overlay {
background-position: center;
background-size: cover;
background-repeat: no-repeat;
opacity: 0.3;
}

View File

@@ -0,0 +1,29 @@
// Editor-specific overrides
// Adjustments for the block editor iframe to match frontend appearance
.editor-styles-wrapper {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
a {
text-decoration-thickness: 1px !important;
text-underline-offset: 0.1em;
}
// Alignment overrides matching Bootstrap container widths
.alignfull {
margin-left: 0;
margin-right: 0;
}
.alignwide {
max-width: 1140px;
margin-left: auto;
margin-right: auto;
}
// Block gap matching theme.json blockGap
> * + * {
margin-block-start: 1.5rem;
}
}

View File

@@ -44,3 +44,7 @@ $enable-dark-mode: true;
// Enable reduced motion
$enable-reduced-motion: true;
// Bootstrap Icons font path (points to copied files in assets/fonts/)
$bootstrap-icons-font-src: url("../fonts/bootstrap-icons.woff2") format("woff2"),
url("../fonts/bootstrap-icons.woff") format("woff");

145
src/scss/_widgets.scss Normal file
View File

@@ -0,0 +1,145 @@
// Widget Bootstrap 5 styling
// Targets sidebar widget inner content rendered by WordPress core widgets.
// Block widgets nest content inside .wp-block-group wrappers.
// Widget headings (block widgets use h4.wp-block-heading after WidgetRenderer transform)
.widget .card-title,
.widget .wp-block-heading {
margin-bottom: $spacer * 0.75;
}
// Widget lists (Recent Posts, Archives, Categories, Recent Comments)
// Covers both legacy (ul direct child) and block widget (ul inside .wp-block-group)
.widget ul,
.widget ol {
list-style: none;
padding-left: 0;
margin-bottom: 0;
li {
padding: $list-group-item-padding-y $list-group-item-padding-x;
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
&:last-child {
border-bottom: 0;
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
// Flush lists to card edges (negate card-body padding)
// Handles both direct children and block widget nesting (.wp-block-group > ul)
.widget .card-body > ul,
.widget .card-body > ol,
.widget .card-body > nav > ul,
.widget .card-body > .wp-block-group > ul,
.widget .card-body > .wp-block-group > ol {
margin: 0 calc(-1 * var(--bs-card-spacer-x)) calc(-1 * var(--bs-card-spacer-y));
li:first-child {
border-top: var(--bs-border-width) solid var(--bs-border-color);
}
}
// Widget select dropdowns (Archives dropdown, Categories dropdown)
.widget select {
display: block;
width: 100%;
padding: $input-padding-y $input-padding-x;
font-size: $input-font-size;
line-height: $input-line-height;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
appearance: auto;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: $input-focus-border-color;
outline: 0;
box-shadow: $input-focus-box-shadow;
}
}
// Widget search form (legacy get_search_form() widgets)
.widget .search-form {
display: flex;
gap: 0.5rem;
.search-field {
flex: 1;
display: block;
width: 100%;
padding: $input-padding-y $input-padding-x;
font-size: $input-font-size;
line-height: $input-line-height;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
&:focus {
border-color: $input-focus-border-color;
outline: 0;
box-shadow: $input-focus-box-shadow;
}
}
.search-submit {
@extend .btn;
@extend .btn-primary;
}
}
// Block search widget — hide label, make input-group flush
.widget .wp-block-search {
.wp-block-search__label {
display: none;
}
}
// Tag cloud
.widget .tagcloud {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
a {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: $font-size-sm !important; // Override inline font-size from WP
line-height: 1.5;
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-pill);
text-decoration: none;
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
&:hover {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #fff;
}
}
}
// Post date in Recent Posts widget
.widget .post-date {
display: block;
font-size: $small-font-size;
color: var(--bs-secondary-color);
}
// Recent Comments styling
.widget .recentcomments {
font-size: $font-size-sm;
}

View File

@@ -1,23 +1,25 @@
/*!
* WP Bootstrap Theme - Editor Styles
* Imports full Bootstrap so WYSIWYG matches the frontend.
*/
// Import Bootstrap functions and variables for consistency
// 1. Import Bootstrap functions first (needed for variable manipulation)
@import "bootstrap/scss/functions";
// 2. Override Bootstrap variables BEFORE they're set
@import "variables";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/root";
// Editor-specific adjustments
.editor-styles-wrapper {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
// 3. Import all of Bootstrap
@import "bootstrap/scss/bootstrap";
a {
text-decoration-thickness: 1px !important;
text-underline-offset: 0.1em;
}
}
// 4. WordPress block compatibility
@import "wordpress";
// 5. Bootstrap Icons
@import "bootstrap-icons/font/bootstrap-icons";
// 6. Custom styles (dark mode overrides, block styles, etc.)
@import "custom";
// 7. Editor-specific overrides
@import "editor-overrides";

11
src/scss/rtl.scss Normal file
View File

@@ -0,0 +1,11 @@
/*!
* WP Bootstrap Theme - RTL Overrides
* Right-to-left language support
*/
// Blockquote accent border (LTR uses border-left, RTL needs border-right)
.is-style-blockquote-accent {
border-left: none;
border-right: 4px solid var(--wp--preset--color--primary);
border-radius: 0.375rem 0 0 0.375rem;
}

View File

@@ -15,5 +15,11 @@
// 4. WordPress block compatibility
@import "wordpress";
// 5. Custom styles
// 5. Bootstrap Icons
@import "bootstrap-icons/font/bootstrap-icons";
// 6. Widget styles
@import "widgets";
// 7. Custom styles
@import "custom";

View File

@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
Requires at least: 6.7
Tested up to: 6.7
Requires PHP: 8.3
Version: 0.1.0
Version: 1.1.2
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-bootstrap

86
styles/05-ember.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Ember",
"settings": {
"color": {
"palette": [
{
"color": "#1c1210",
"name": "Base",
"slug": "base"
},
{
"color": "#f0e6e0",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#e85d26",
"name": "Primary",
"slug": "primary"
},
{
"color": "#a8a09c",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#4ade80",
"name": "Success",
"slug": "success"
},
{
"color": "#f87171",
"name": "Danger",
"slug": "danger"
},
{
"color": "#fbbf24",
"name": "Warning",
"slug": "warning"
},
{
"color": "#fb923c",
"name": "Info",
"slug": "info"
},
{
"color": "#2a1f1b",
"name": "Light",
"slug": "light"
},
{
"color": "#3d2117",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#c44b1b"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#c44b1b"
}
}
}
}
}
}

86
styles/06-arctic.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Arctic",
"settings": {
"color": {
"palette": [
{
"color": "#0d1b2a",
"name": "Base",
"slug": "base"
},
{
"color": "#e0e8f0",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#38bdf8",
"name": "Primary",
"slug": "primary"
},
{
"color": "#94a3b8",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#34d399",
"name": "Success",
"slug": "success"
},
{
"color": "#fb7185",
"name": "Danger",
"slug": "danger"
},
{
"color": "#facc15",
"name": "Warning",
"slug": "warning"
},
{
"color": "#22d3ee",
"name": "Info",
"slug": "info"
},
{
"color": "#1b2838",
"name": "Light",
"slug": "light"
},
{
"color": "#0f4c75",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#0ea5e9"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#0ea5e9"
}
}
}
}
}
}

86
styles/07-amethyst.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Amethyst",
"settings": {
"color": {
"palette": [
{
"color": "#1a1028",
"name": "Base",
"slug": "base"
},
{
"color": "#e8e0f0",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#a78bfa",
"name": "Primary",
"slug": "primary"
},
{
"color": "#9ca3af",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#6ee7b7",
"name": "Success",
"slug": "success"
},
{
"color": "#fca5a5",
"name": "Danger",
"slug": "danger"
},
{
"color": "#fde68a",
"name": "Warning",
"slug": "warning"
},
{
"color": "#c084fc",
"name": "Info",
"slug": "info"
},
{
"color": "#221538",
"name": "Light",
"slug": "light"
},
{
"color": "#3b1f6e",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#8b5cf6"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#8b5cf6"
}
}
}
}
}
}

79
styles/08-rose.json Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Rose",
"settings": {
"color": {
"palette": [
{
"color": "#FFFBFC",
"name": "Base",
"slug": "base"
},
{
"color": "#2e1a21",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#be185d",
"name": "Primary",
"slug": "primary"
},
{
"color": "#78716c",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#16a34a",
"name": "Success",
"slug": "success"
},
{
"color": "#dc2626",
"name": "Danger",
"slug": "danger"
},
{
"color": "#eab308",
"name": "Warning",
"slug": "warning"
},
{
"color": "#ec4899",
"name": "Info",
"slug": "info"
},
{
"color": "#fdf2f8",
"name": "Light",
"slug": "light"
},
{
"color": "#4a1030",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
":hover": {
"color": {
"background": "#9d174d"
}
}
},
"link": {
":hover": {
"color": {
"text": "#9d174d"
}
}
}
}
}
}

79
styles/09-sand.json Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Sand",
"settings": {
"color": {
"palette": [
{
"color": "#FEFCF8",
"name": "Base",
"slug": "base"
},
{
"color": "#292017",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#b45309",
"name": "Primary",
"slug": "primary"
},
{
"color": "#78716c",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#15803d",
"name": "Success",
"slug": "success"
},
{
"color": "#b91c1c",
"name": "Danger",
"slug": "danger"
},
{
"color": "#d97706",
"name": "Warning",
"slug": "warning"
},
{
"color": "#d4956b",
"name": "Info",
"slug": "info"
},
{
"color": "#f5f0e8",
"name": "Light",
"slug": "light"
},
{
"color": "#3d2c14",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
":hover": {
"color": {
"background": "#92400e"
}
}
},
"link": {
":hover": {
"color": {
"text": "#92400e"
}
}
}
}
}
}

79
styles/10-lavender.json Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Lavender",
"settings": {
"color": {
"palette": [
{
"color": "#FCFBFF",
"name": "Base",
"slug": "base"
},
{
"color": "#1e1b2e",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#7c3aed",
"name": "Primary",
"slug": "primary"
},
{
"color": "#71717a",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#059669",
"name": "Success",
"slug": "success"
},
{
"color": "#dc2626",
"name": "Danger",
"slug": "danger"
},
{
"color": "#eab308",
"name": "Warning",
"slug": "warning"
},
{
"color": "#8b5cf6",
"name": "Info",
"slug": "info"
},
{
"color": "#f3f0ff",
"name": "Light",
"slug": "light"
},
{
"color": "#3b1f8e",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
":hover": {
"color": {
"background": "#6d28d9"
}
}
},
"link": {
":hover": {
"color": {
"text": "#6d28d9"
}
}
}
}
}
}

79
styles/11-mint.json Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Mint",
"settings": {
"color": {
"palette": [
{
"color": "#F8FFFE",
"name": "Base",
"slug": "base"
},
{
"color": "#14291f",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#0d9488",
"name": "Primary",
"slug": "primary"
},
{
"color": "#6b7280",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#16a34a",
"name": "Success",
"slug": "success"
},
{
"color": "#dc2626",
"name": "Danger",
"slug": "danger"
},
{
"color": "#ca8a04",
"name": "Warning",
"slug": "warning"
},
{
"color": "#2dd4bf",
"name": "Info",
"slug": "info"
},
{
"color": "#ecfdf5",
"name": "Light",
"slug": "light"
},
{
"color": "#134e4a",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
":hover": {
"color": {
"background": "#0f766e"
}
}
},
"link": {
":hover": {
"color": {
"text": "#0f766e"
}
}
}
}
}
}

86
styles/12-slate.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Slate",
"settings": {
"color": {
"palette": [
{
"color": "#0f172a",
"name": "Base",
"slug": "base"
},
{
"color": "#e2e8f0",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#3b82f6",
"name": "Primary",
"slug": "primary"
},
{
"color": "#94a3b8",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#22c55e",
"name": "Success",
"slug": "success"
},
{
"color": "#ef4444",
"name": "Danger",
"slug": "danger"
},
{
"color": "#eab308",
"name": "Warning",
"slug": "warning"
},
{
"color": "#06b6d4",
"name": "Info",
"slug": "info"
},
{
"color": "#1e293b",
"name": "Light",
"slug": "light"
},
{
"color": "#334155",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#2563eb"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#2563eb"
}
}
}
}
}
}

86
styles/13-mocha.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Mocha",
"settings": {
"color": {
"palette": [
{
"color": "#1c1412",
"name": "Base",
"slug": "base"
},
{
"color": "#e7ddd4",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#c49a6c",
"name": "Primary",
"slug": "primary"
},
{
"color": "#a8998a",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#6ec98f",
"name": "Success",
"slug": "success"
},
{
"color": "#e87e7e",
"name": "Danger",
"slug": "danger"
},
{
"color": "#e5c07b",
"name": "Warning",
"slug": "warning"
},
{
"color": "#d19a66",
"name": "Info",
"slug": "info"
},
{
"color": "#2a201a",
"name": "Light",
"slug": "light"
},
{
"color": "#45342a",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#a8804f"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#a8804f"
}
}
}
}
}
}

86
styles/14-nebula.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Nebula",
"settings": {
"color": {
"palette": [
{
"color": "#0a1628",
"name": "Base",
"slug": "base"
},
{
"color": "#d4e4f0",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#2dd4bf",
"name": "Primary",
"slug": "primary"
},
{
"color": "#7e9bb0",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#4ade80",
"name": "Success",
"slug": "success"
},
{
"color": "#f472b6",
"name": "Danger",
"slug": "danger"
},
{
"color": "#fbbf24",
"name": "Warning",
"slug": "warning"
},
{
"color": "#67e8f9",
"name": "Info",
"slug": "info"
},
{
"color": "#112240",
"name": "Light",
"slug": "light"
},
{
"color": "#1a365d",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#14b8a6"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#14b8a6"
}
}
}
}
}
}

86
styles/15-obsidian.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"title": "Obsidian",
"settings": {
"color": {
"palette": [
{
"color": "#121212",
"name": "Base",
"slug": "base"
},
{
"color": "#e0e0e0",
"name": "Contrast",
"slug": "contrast"
},
{
"color": "#ef4444",
"name": "Primary",
"slug": "primary"
},
{
"color": "#a3a3a3",
"name": "Secondary",
"slug": "secondary"
},
{
"color": "#4ade80",
"name": "Success",
"slug": "success"
},
{
"color": "#fb7185",
"name": "Danger",
"slug": "danger"
},
{
"color": "#fbbf24",
"name": "Warning",
"slug": "warning"
},
{
"color": "#f97316",
"name": "Info",
"slug": "info"
},
{
"color": "#1a1a1a",
"name": "Light",
"slug": "light"
},
{
"color": "#2a2a2a",
"name": "Dark",
"slug": "dark"
}
]
}
},
"styles": {
"elements": {
"button": {
"color": {
"background": "var:preset|color|primary",
"text": "var:preset|color|base"
},
":hover": {
"color": {
"background": "#dc2626"
}
}
},
"link": {
"color": {
"text": "var:preset|color|primary"
},
":hover": {
"color": {
"text": "#dc2626"
}
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
<!-- wp:template-part {"slug":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"default"}} -->
<main class="wp-block-group">
<!-- wp:post-content {"align":"full","layout":{"type":"default"}} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer"} /-->

14
templates/page-hero.html Normal file
View File

@@ -0,0 +1,14 @@
<!-- wp:template-part {"slug":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"default"}} -->
<main class="wp-block-group">
<!-- wp:pattern {"slug":"wp-bootstrap/hero-cover"} /-->
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</div>
<!-- /wp:group -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer"} /-->

View File

@@ -0,0 +1,5 @@
<!-- wp:group {"tagName":"main","layout":{"type":"default"}} -->
<main class="wp-block-group">
<!-- wp:post-content {"align":"full","layout":{"type":"default"}} /-->
</main>
<!-- /wp:group -->

View File

@@ -0,0 +1,25 @@
<!-- wp:template-part {"slug":"header"} /-->
<!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<main class="wp-block-group" style="margin-top:var(--wp--preset--spacing--60)">
<!-- wp:columns {"align":"wide","style":{"spacing":{"blockGap":{"left":"var:preset|spacing|60"}}}} -->
<div class="wp-block-columns alignwide">
<!-- wp:column {"width":"66.66%"} -->
<div class="wp-block-column" style="flex-basis:66.66%">
<!-- wp:post-featured-image {"style":{"spacing":{"margin":{"bottom":"var:preset|spacing|60"}}}} /-->
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"33.33%"} -->
<div class="wp-block-column" style="flex-basis:33.33%">
<!-- wp:template-part {"slug":"sidebar"} /-->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer"} /-->

10
tests/Stubs/WpBlock.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
/**
* Stub for WordPress WP_Block class.
*
* Used as a type hint in BlockRenderer handler methods.
* Only needs to exist — no functionality required.
*/
class WP_Block
{
}

View File

@@ -0,0 +1,135 @@
<?php
/**
* Functional stub for WordPress WP_HTML_Tag_Processor.
*
* Uses DOMDocument/DOMXPath to provide the subset of functionality
* used by BlockRenderer: next_tag(), add_class(), get_updated_html().
*
* This is NOT a complete implementation — only the methods and query
* modes actually used in the theme are supported.
*/
class WP_HTML_Tag_Processor
{
private string $html;
private \DOMDocument $doc;
private \DOMXPath $xpath;
private ?\DOMElement $current = null;
/** @var int[] Object IDs of already-visited elements. */
private array $visited = [];
public function __construct(string $html)
{
$this->html = $html;
$this->doc = new \DOMDocument();
$this->doc->encoding = 'UTF-8';
// Wrap in a full HTML document so <body> is always present.
// This ensures get_updated_html() can reliably extract content.
@$this->doc->loadHTML(
'<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>'
. $html
. '</body></html>',
LIBXML_HTML_NODEFDTD
);
$this->xpath = new \DOMXPath($this->doc);
}
/**
* Advance to the next matching tag.
*
* @param string|array|null $query Tag name (string) or ['class_name' => '...'] (array).
*/
public function next_tag($query = null): bool
{
$tagName = null;
$className = null;
if (is_string($query)) {
$tagName = strtolower($query);
} elseif (is_array($query)) {
$tagName = isset($query['tag_name']) ? strtolower($query['tag_name']) : null;
$className = $query['class_name'] ?? null;
}
// Build XPath — search within <body> only.
$body = $this->doc->getElementsByTagName('body')->item(0);
if (!$body) {
return false;
}
$xpathExpr = './/*';
$conditions = [];
if ($tagName !== null) {
$conditions[] = sprintf(
"translate(local-name(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = '%s'",
$tagName
);
}
if ($className !== null) {
$conditions[] = sprintf(
"contains(concat(' ', normalize-space(@class), ' '), ' %s ')",
$className
);
}
if ($conditions) {
$xpathExpr .= '[' . implode(' and ', $conditions) . ']';
}
$nodes = $this->xpath->query($xpathExpr, $body);
foreach ($nodes as $node) {
$oid = spl_object_id($node);
if (!in_array($oid, $this->visited, true)) {
$this->current = $node;
$this->visited[] = $oid;
return true;
}
}
$this->current = null;
return false;
}
/**
* Add a CSS class to the current tag (idempotent).
*/
public function add_class(string $className): bool
{
if ($this->current === null) {
return false;
}
$existing = $this->current->getAttribute('class');
$classes = $existing ? array_filter(explode(' ', $existing)) : [];
if (!in_array($className, $classes, true)) {
$classes[] = $className;
}
$this->current->setAttribute('class', implode(' ', $classes));
return true;
}
/**
* Return the modified HTML.
*/
public function get_updated_html(): string
{
$body = $this->doc->getElementsByTagName('body')->item(0);
if (!$body) {
return $this->html;
}
$html = '';
foreach ($body->childNodes as $child) {
$html .= $this->doc->saveHTML($child);
}
return $html;
}
}

10
tests/Stubs/WpWidget.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
/**
* Stub for WordPress WP_Widget class.
*
* Used as a type hint in WidgetRenderer::processBlockWidgetContent().
* Only needs to exist — no functionality required.
*/
class WP_Widget
{
}

View File

@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Block;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Block\BlockRenderer;
class BlockRendererTest extends TestCase
{
private BlockRenderer $renderer;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Return true so the constructor skips add_filter() registration.
Functions\when('is_admin')->justReturn(true);
Functions\when('wp_doing_ajax')->justReturn(false);
$this->renderer = new BlockRenderer();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── core/table ──────────────────────────────────────────────
public function testRenderTableAddsTableClass(): void
{
$html = '<figure class="wp-block-table"><table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderTable($html, $block);
$this->assertStringContainsString('table', $this->classesOf('table', $result));
}
public function testRenderTableWithStripesAddsStripedClass(): void
{
$html = '<figure class="wp-block-table is-style-stripes"><table><tr><td>1</td></tr></table></figure>';
$block = ['attrs' => ['className' => 'is-style-stripes']];
$result = $this->renderer->renderTable($html, $block);
$classes = $this->classesOf('table', $result);
$this->assertStringContainsString('table', $classes);
$this->assertStringContainsString('table-striped', $classes);
}
public function testRenderTableEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderTable('', []));
}
public function testRenderTableWithoutTableTagReturnsOriginal(): void
{
$html = '<p>No table here</p>';
$this->assertSame($html, $this->renderer->renderTable($html, []));
}
// ── core/button ─────────────────────────────────────────────
public function testRenderButtonAddsBtnPrimaryByDefault(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => []];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringContainsString('btn-primary', $classes);
}
public function testRenderButtonWithBackgroundColor(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['backgroundColor' => 'danger']];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringContainsString('btn-danger', $classes);
}
public function testRenderButtonOutlineStyle(): void
{
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['className' => 'is-style-outline', 'textColor' => 'success']];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringContainsString('btn-outline-success', $classes);
}
public function testRenderButtonOutlineDefaultsToPrimary(): void
{
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['className' => 'is-style-outline']];
$result = $this->renderer->renderButton($html, $block);
$this->assertStringContainsString('btn-outline-primary', $this->classesOf('a', $result));
}
public function testRenderButtonWithGradientOnlyAddsBtnBase(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['gradient' => 'vivid-cyan-blue']];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringNotContainsString('btn-primary', $classes);
}
public function testRenderButtonWithUnknownColorDefaultsToPrimary(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['backgroundColor' => 'custom-teal']];
$result = $this->renderer->renderButton($html, $block);
$this->assertStringContainsString('btn-primary', $this->classesOf('a', $result));
}
public function testRenderButtonEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderButton('', []));
}
// ── core/buttons ────────────────────────────────────────────
public function testRenderButtonsAddsFlexClasses(): void
{
$html = '<div class="wp-block-buttons"><div class="wp-block-button"><a class="wp-block-button__link" href="#">A</a></div></div>';
$block = ['attrs' => []];
$result = $this->renderer->renderButtons($html, $block);
$classes = $this->classesOfFirst('.wp-block-buttons', $result);
$this->assertStringContainsString('d-flex', $classes);
$this->assertStringContainsString('flex-wrap', $classes);
$this->assertStringContainsString('gap-2', $classes);
}
public function testRenderButtonsEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderButtons('', []));
}
// ── core/image ──────────────────────────────────────────────
public function testRenderImageAddsImgFluid(): void
{
$html = '<figure class="wp-block-image"><img src="photo.jpg" alt="Photo"></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderImage($html, $block);
$this->assertStringContainsString('img-fluid', $this->classesOf('img', $result));
}
public function testRenderImageEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderImage('', []));
}
// ── core/search ─────────────────────────────────────────────
public function testRenderSearchAddsBootstrapClasses(): void
{
$html = '<form class="wp-block-search" role="search">'
. '<div class="wp-block-search__inside-wrapper">'
. '<input class="wp-block-search__input" type="search" placeholder="Search">'
. '<button class="wp-block-search__button" type="submit">Search</button>'
. '</div></form>';
$block = ['attrs' => []];
$result = $this->renderer->renderSearch($html, $block);
$this->assertStringContainsString('input-group', $result);
$this->assertStringContainsString('form-control', $result);
$this->assertStringContainsString('btn-primary', $result);
}
public function testRenderSearchEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderSearch('', []));
}
// ── core/quote ──────────────────────────────────────────────
public function testRenderQuoteAddsBlockquoteClass(): void
{
$html = '<blockquote class="wp-block-quote"><p>Quote text</p></blockquote>';
$block = ['attrs' => []];
$result = $this->renderer->renderQuote($html, $block);
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
}
public function testRenderQuoteWithCiteAddsFooterClass(): void
{
$html = '<blockquote class="wp-block-quote"><p>Quote</p><cite>Author</cite></blockquote>';
$block = ['attrs' => []];
$result = $this->renderer->renderQuote($html, $block);
$this->assertStringContainsString('blockquote-footer', $result);
}
public function testRenderQuoteWithoutCite(): void
{
$html = '<blockquote class="wp-block-quote"><p>Quote only</p></blockquote>';
$block = ['attrs' => []];
$result = $this->renderer->renderQuote($html, $block);
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
$this->assertStringNotContainsString('blockquote-footer', $result);
}
public function testRenderQuoteEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderQuote('', []));
}
// ── core/pullquote ──────────────────────────────────────────
public function testRenderPullquoteAddsBlockquoteClass(): void
{
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p></blockquote></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderPullquote($html, $block);
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
}
public function testRenderPullquoteWithCiteAddsFooterClass(): void
{
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p><cite>Source</cite></blockquote></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderPullquote($html, $block);
$this->assertStringContainsString('blockquote-footer', $result);
}
public function testRenderPullquoteEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderPullquote('', []));
}
// ── core/list ───────────────────────────────────────────────
public function testRenderListGroupAddsClasses(): void
{
$html = '<ul class="is-style-list-group"><li>A</li><li>B</li><li>C</li></ul>';
$block = ['attrs' => ['className' => 'is-style-list-group']];
$result = $this->renderer->renderList($html, $block);
$this->assertStringContainsString('list-group', $this->classesOf('ul', $result));
$this->assertStringContainsString('list-group-item', $result);
}
public function testRenderListGroupOrderedList(): void
{
$html = '<ol class="is-style-list-group"><li>1</li><li>2</li></ol>';
$block = ['attrs' => ['className' => 'is-style-list-group', 'ordered' => true]];
$result = $this->renderer->renderList($html, $block);
$this->assertStringContainsString('list-group', $this->classesOf('ol', $result));
}
public function testRenderListWithoutGroupStyleReturnsUnchanged(): void
{
$html = '<ul><li>A</li></ul>';
$block = ['attrs' => []];
$result = $this->renderer->renderList($html, $block);
$this->assertStringNotContainsString('list-group', $result);
}
public function testRenderListEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderList('', []));
}
// ── helpers ─────────────────────────────────────────────────
/**
* Extract the class attribute value of the first matching tag.
*/
private function classesOf(string $tagName, string $html): string
{
$doc = new \DOMDocument();
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
$elements = $doc->getElementsByTagName($tagName);
return $elements->length > 0 ? ($elements->item(0)->getAttribute('class') ?? '') : '';
}
/**
* Extract classes from the first element matching a CSS selector-style class.
*/
private function classesOfFirst(string $selector, string $html): string
{
$className = ltrim($selector, '.');
$doc = new \DOMDocument();
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
$xpath = new \DOMXPath($doc);
$body = $doc->getElementsByTagName('body')->item(0);
if (!$body) {
return '';
}
$nodes = $xpath->query(sprintf(
".//*[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]",
$className
), $body);
return $nodes->length > 0 ? ($nodes->item(0)->getAttribute('class') ?? '') : '';
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Block;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Block\WidgetRenderer;
class WidgetRendererTest extends TestCase
{
private WidgetRenderer $renderer;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Skip constructor filter registration.
Functions\when('is_admin')->justReturn(true);
Functions\when('wp_doing_ajax')->justReturn(false);
$this->renderer = new WidgetRenderer();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── wrapWidgetInCard ────────────────────────────────────────
public function testWrapWidgetInCardSetsCardStructure(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('card', $result[0]['before_widget']);
$this->assertStringContainsString('card-body', $result[0]['before_widget']);
$this->assertSame('</div></div>', $result[0]['after_widget']);
}
public function testWrapWidgetInCardSetsCardTitle(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-3', 'widget mb-4 widget_block');
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('<h4', $result[0]['before_title']);
$this->assertStringContainsString('card-title', $result[0]['before_title']);
$this->assertSame('</h4>', $result[0]['after_title']);
}
public function testWrapWidgetInCardPreservesWidgetId(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('search-1', 'widget mb-4 widget_search');
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('id="search-1"', $result[0]['before_widget']);
}
public function testWrapWidgetInCardPreservesWidgetTypeClasses(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-5', 'widget mb-4 widget_block wp-block-heading');
$result = $this->renderer->wrapWidgetInCard($params);
// widget_block and wp-block-heading are kept; widget and mb-4 are removed.
$this->assertStringContainsString('widget_block', $result[0]['before_widget']);
$this->assertStringContainsString('wp-block-heading', $result[0]['before_widget']);
}
public function testWrapWidgetInCardFiltersGenericClasses(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
$result = $this->renderer->wrapWidgetInCard($params);
// The card wrapper adds its own "widget" class, but the extracted
// classes should not include the generic "widget" or "mb-4".
// Count occurrences: "card mb-3 widget " + "widget_block" should have exactly one "widget".
preg_match_all('/\bwidget\b/', $result[0]['before_widget'], $matches);
$this->assertCount(1, $matches[0], 'Generic "widget" class should appear once (from card wrapper), not duplicated');
}
public function testWrapWidgetInCardWithNoExistingClasses(): void
{
Functions\when('esc_attr')->returnArg();
$params = [
[
'widget_id' => 'text-1',
'before_widget' => '<div id="text-1">',
'after_widget' => '</div>',
'before_title' => '<h3>',
'after_title' => '</h3>',
],
];
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('card', $result[0]['before_widget']);
$this->assertStringContainsString('id="text-1"', $result[0]['before_widget']);
}
// ── processBlockWidgetContent ───────────────────────────────
public function testProcessBlockWidgetContentReplacesH2WithH4(): void
{
$content = '<h2 class="wp-block-heading">Categories</h2><ul><li>Cat A</li></ul>';
$widget = new \WP_Widget();
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
$this->assertStringContainsString('<h4 class="wp-block-heading">', $result);
$this->assertStringContainsString('</h4>', $result);
$this->assertStringNotContainsString('<h2', $result);
$this->assertStringNotContainsString('</h2>', $result);
}
public function testProcessBlockWidgetContentPreservesOtherH2(): void
{
$content = '<h2 class="custom-heading">Keep me</h2>';
$widget = new \WP_Widget();
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
// h2 without wp-block-heading class should remain.
$this->assertStringContainsString('<h2 class="custom-heading">', $result);
}
public function testProcessBlockWidgetContentEmptyReturnsEmpty(): void
{
$widget = new \WP_Widget();
$this->assertSame('', $this->renderer->processBlockWidgetContent('', [], $widget));
}
public function testProcessBlockWidgetContentMultipleH2(): void
{
$content = '<h2 class="wp-block-heading">First</h2><p>Text</p><h2 class="wp-block-heading">Second</h2>';
$widget = new \WP_Widget();
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
$this->assertSame(0, substr_count($result, '<h2'));
$this->assertSame(2, substr_count($result, '<h4'));
}
// ── helpers ─────────────────────────────────────────────────
private function makeParams(string $widgetId, string $classes): array
{
return [
[
'widget_id' => $widgetId,
'before_widget' => sprintf('<div id="%s" class="%s">', $widgetId, $classes),
'after_widget' => '</div>',
'before_title' => '<h3 class="sidebar-heading">',
'after_title' => '</h3>',
],
];
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Template;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Template\NavWalker;
class NavWalkerTest extends TestCase
{
private NavWalker $walker;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Default: no page or category is active.
Functions\when('is_page')->justReturn(false);
Functions\when('is_category')->justReturn(false);
$this->walker = new NavWalker();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── Tree structure ──────────────────────────────────────────
public function testBuildTreeWithEmptyArray(): void
{
$this->assertSame([], $this->walker->buildTree([]));
}
public function testBuildTreeWithSingleItem(): void
{
$items = [$this->makeItem(10, 'Home', '/')];
$tree = $this->walker->buildTree($items);
$this->assertCount(1, $tree);
$this->assertSame('Home', $tree[0]['title']);
$this->assertSame('/', $tree[0]['url']);
}
public function testBuildTreeFlatItemsAllTopLevel(): void
{
$items = [
$this->makeItem(1, 'A', '/a'),
$this->makeItem(2, 'B', '/b'),
$this->makeItem(3, 'C', '/c'),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(3, $tree);
foreach ($tree as $node) {
$this->assertEmpty($node['children']);
}
}
public function testBuildTreeWithChildren(): void
{
$items = [
$this->makeItem(10, 'Home', '/'),
$this->makeItem(20, 'About', '/about'),
$this->makeItem(30, 'Team', '/about/team', parent: 20),
$this->makeItem(40, 'Jobs', '/about/jobs', parent: 20),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(2, $tree);
$this->assertSame('About', $tree[1]['title']);
$this->assertCount(2, $tree[1]['children']);
$this->assertSame('Team', $tree[1]['children'][0]['title']);
$this->assertSame('Jobs', $tree[1]['children'][1]['title']);
}
public function testBuildTreeWithMultipleParents(): void
{
$items = [
$this->makeItem(1, 'A', '/a'),
$this->makeItem(2, 'B', '/b'),
$this->makeItem(3, 'A1', '/a/1', parent: 1),
$this->makeItem(4, 'B1', '/b/1', parent: 2),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(2, $tree);
$this->assertCount(1, $tree[0]['children']);
$this->assertCount(1, $tree[1]['children']);
$this->assertSame('A1', $tree[0]['children'][0]['title']);
$this->assertSame('B1', $tree[1]['children'][0]['title']);
}
public function testBuildTreeOrphansAreDropped(): void
{
$items = [
$this->makeItem(1, 'Root', '/'),
$this->makeItem(2, 'Orphan', '/orphan', parent: 999),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(1, $tree);
$this->assertSame('Root', $tree[0]['title']);
}
public function testBuildTreeNodeStructure(): void
{
$items = [$this->makeItem(42, 'Page', '/page', target: '_blank', classes: ['menu-item', 'custom'])];
$tree = $this->walker->buildTree($items);
$node = $tree[0];
$this->assertSame(42, $node['id']);
$this->assertSame('Page', $node['title']);
$this->assertSame('/page', $node['url']);
$this->assertSame('_blank', $node['target']);
$this->assertSame('menu-item custom', $node['classes']);
$this->assertIsBool($node['active']);
$this->assertIsArray($node['children']);
}
public function testBuildTreeClassesJoined(): void
{
$items = [$this->makeItem(1, 'X', '/x', classes: ['nav-item', '', 'menu-item'])];
$tree = $this->walker->buildTree($items);
// Empty strings are filtered out by array_filter.
$this->assertSame('nav-item menu-item', $tree[0]['classes']);
}
public function testBuildTreeIndexIsReset(): void
{
$items = [
$this->makeItem(50, 'A', '/a'),
$this->makeItem(100, 'B', '/b'),
];
$tree = $this->walker->buildTree($items);
// array_values resets keys to 0-indexed.
$this->assertArrayHasKey(0, $tree);
$this->assertArrayHasKey(1, $tree);
$this->assertArrayNotHasKey(50, $tree);
}
// ── Active detection ────────────────────────────────────────
public function testBuildTreeSetsActiveViaCurrentMenuItem(): void
{
$items = [$this->makeItem(1, 'Active', '/active', classes: ['current-menu-item'])];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeSetsActiveViaAncestor(): void
{
$items = [$this->makeItem(1, 'Parent', '/parent', classes: ['current-menu-ancestor'])];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeSetsActiveViaIsPage(): void
{
Functions\when('is_page')->alias(fn(int $id): bool => $id === 42);
$items = [$this->makeItem(1, 'Contact', '/contact', object: 'page', objectId: 42)];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeSetsActiveViaIsCategory(): void
{
Functions\when('is_category')->alias(fn(int $id): bool => $id === 7);
$items = [$this->makeItem(1, 'News', '/news', object: 'category', objectId: 7)];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeNotActive(): void
{
$items = [$this->makeItem(1, 'Inactive', '/inactive')];
$tree = $this->walker->buildTree($items);
$this->assertFalse($tree[0]['active']);
}
// ── Helper ──────────────────────────────────────────────────
private function makeItem(
int $id,
string $title,
string $url,
int $parent = 0,
string $target = '',
array $classes = [],
string $object = 'page',
int $objectId = 0,
): object {
return (object) [
'ID' => $id,
'title' => $title,
'url' => $url,
'target' => $target,
'classes' => $classes,
'menu_item_parent' => $parent,
'object' => $object,
'object_id' => $objectId ?: $id,
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Template;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Template\TemplateController;
use ReflectionMethod;
class TemplateControllerTest extends TestCase
{
private TemplateController $controller;
private ReflectionMethod $resolveTemplate;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// TemplateController constructor: new ContextBuilder() + add_action().
// ContextBuilder constructor: new NavWalker() (no WP calls).
Functions\when('is_admin')->justReturn(true);
Functions\when('wp_doing_ajax')->justReturn(false);
Functions\when('add_action')->justReturn(true);
// Default: all WP conditionals return false.
Functions\when('is_404')->justReturn(false);
Functions\when('is_search')->justReturn(false);
Functions\when('is_singular')->justReturn(false);
Functions\when('is_page')->justReturn(false);
Functions\when('is_archive')->justReturn(false);
Functions\when('is_home')->justReturn(false);
Functions\when('is_category')->justReturn(false);
Functions\when('get_page_template_slug')->justReturn('');
$this->controller = new TemplateController();
$this->resolveTemplate = new ReflectionMethod($this->controller, 'resolveTemplate');
$this->resolveTemplate->setAccessible(true);
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
private function resolve(): ?string
{
return $this->resolveTemplate->invoke($this->controller);
}
// ── Template resolution ─────────────────────────────────────
public function testResolveTemplate404(): void
{
Functions\when('is_404')->justReturn(true);
$this->assertSame('pages/404.html.twig', $this->resolve());
}
public function testResolveTemplateSearch(): void
{
Functions\when('is_search')->justReturn(true);
$this->assertSame('pages/search.html.twig', $this->resolve());
}
public function testResolveTemplateSinglePostDefault(): void
{
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
$this->assertSame('pages/single-sidebar.html.twig', $this->resolve());
}
public function testResolveTemplateSinglePostFullWidth(): void
{
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
Functions\when('get_page_template_slug')->justReturn('page-full-width');
$this->assertSame('pages/single.html.twig', $this->resolve());
}
public function testResolveTemplatePageDefault(): void
{
Functions\when('is_page')->justReturn(true);
$this->assertSame('pages/page.html.twig', $this->resolve());
}
public function testResolveTemplatePageLanding(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-landing');
$this->assertSame('pages/landing.html.twig', $this->resolve());
}
public function testResolveTemplatePageFullWidth(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-full-width');
$this->assertSame('pages/full-width.html.twig', $this->resolve());
}
public function testResolveTemplatePageHero(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-hero');
$this->assertSame('pages/hero.html.twig', $this->resolve());
}
public function testResolveTemplatePageSidebar(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-sidebar');
$this->assertSame('pages/page-sidebar.html.twig', $this->resolve());
}
public function testResolveTemplateArchive(): void
{
Functions\when('is_archive')->justReturn(true);
$this->assertSame('pages/archive.html.twig', $this->resolve());
}
public function testResolveTemplateHome(): void
{
Functions\when('is_home')->justReturn(true);
$this->assertSame('pages/index.html.twig', $this->resolve());
}
public function testResolveTemplateFallback(): void
{
// All conditionals already return false in setUp.
$this->assertSame('pages/index.html.twig', $this->resolve());
}
}

15
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
/**
* PHPUnit bootstrap file.
*
* Loads Composer autoloader and WordPress class stubs
* required for unit testing outside of WordPress.
*/
// Composer autoloader (loads WPBootstrap\* classes + Brain\Monkey).
require_once dirname(__DIR__) . '/vendor/autoload.php';
// WordPress class stubs (global namespace).
require_once __DIR__ . '/Stubs/WpHtmlTagProcessor.php';
require_once __DIR__ . '/Stubs/WpBlock.php';
require_once __DIR__ . '/Stubs/WpWidget.php';

View File

@@ -215,13 +215,62 @@
}
]
},
"shadow": {
"defaultPresets": false,
"presets": [
{
"name": "Small",
"slug": "sm",
"shadow": "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)"
},
{
"name": "Regular",
"slug": "md",
"shadow": "0 0.5rem 1rem rgba(0, 0, 0, 0.15)"
},
{
"name": "Large",
"slug": "lg",
"shadow": "0 1rem 3rem rgba(0, 0, 0, 0.175)"
}
]
},
"dimensions": {
"aspectRatios": [
{
"name": "16:9",
"slug": "16-9",
"ratio": "16/9"
},
{
"name": "4:3",
"slug": "4-3",
"ratio": "4/3"
},
{
"name": "1:1",
"slug": "1-1",
"ratio": "1/1"
},
{
"name": "3:4",
"slug": "3-4",
"ratio": "3/4"
}
]
},
"custom": {
"layout": {
"contentSize": "720px",
"wideSize": "1140px"
},
"spacing": {
"baseline": "1.5rem"
}
},
"useRootPaddingAwareAlignments": true
},
"styles": {
"color": {
"background": "var:preset|color|base",
"text": "var:preset|color|contrast"
},
"spacing": {
"blockGap": "1.5rem",
"padding": {
@@ -450,6 +499,26 @@
"name": "home-sidebar",
"postTypes": ["page"],
"title": "Blog with Sidebar"
},
{
"name": "page-landing",
"postTypes": ["page"],
"title": "Landing Page (No Header/Footer)"
},
{
"name": "page-full-width",
"postTypes": ["page"],
"title": "Full Width"
},
{
"name": "page-hero",
"postTypes": ["page"],
"title": "Page with Hero"
},
{
"name": "page-sidebar",
"postTypes": ["page", "post"],
"title": "Page with Sidebar"
}
],
"templateParts": [
@@ -458,11 +527,31 @@
"name": "header",
"title": "Header"
},
{
"area": "header",
"name": "header-centered",
"title": "Header - Centered"
},
{
"area": "header",
"name": "header-transparent",
"title": "Header - Transparent"
},
{
"area": "footer",
"name": "footer",
"title": "Footer"
},
{
"area": "footer",
"name": "footer-minimal",
"title": "Footer - Minimal"
},
{
"area": "footer",
"name": "footer-columns",
"title": "Footer - Multi-Column"
},
{
"area": "uncategorized",
"name": "sidebar",

View File

28
views/base.html.twig Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html {{ language_attributes() }}>
<head>
<meta charset="{{ charset }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ wp_head() }}
</head>
<body {{ body_class() }}>
{{ wp_body_open() }}
<a class="wp-bootstrap-skip-link" href="#main-content">{{ __('Skip to main content') }}</a>
{% block header %}
{% include 'partials/header-offcanvas.html.twig' %}
{% endblock %}
<main id="main-content" class="{% block main_class %}py-4{% endblock %}">
{% block content %}{% endblock %}
</main>
{% block footer %}
{# block_template_part('footer') #}
{% include 'partials/footer-columns.html.twig' %}
{% endblock %}
{{ wp_footer() }}
</body>
</html>

View File

@@ -0,0 +1,17 @@
<article class="card h-100">
{% if post.thumbnail %}
<a href="{{ post.url }}">
<img src="{{ post.thumbnail }}" class="card-img-top card-thumbnail"
alt="{{ post.title|e('html_attr') }}"
loading="lazy">
</a>
{% endif %}
<div class="card-body">
<h3 class="card-title h6">
<a href="{{ post.url }}" class="text-decoration-none text-body">{{ post.title }}</a>
</h3>
<p class="card-text text-body-secondary small">
<time datetime="{{ post.date_iso }}">{{ post.date }}</time>
</p>
</div>
</article>

View File

@@ -0,0 +1,21 @@
<article class="card mb-4 border-0 border-bottom rounded-0 pb-4">
{% if post.thumbnail %}
<a href="{{ post.url }}">
<img src="{{ post.thumbnail }}" class="card-img-top rounded" alt="{{ post.title|e('html_attr') }}" loading="lazy">
</a>
{% endif %}
<div class="card-body px-0">
<h2 class="card-title h4">
<a href="{{ post.url }}" class="text-decoration-none text-body">{{ post.title }}</a>
</h2>
<div class="text-body-secondary small mb-2">
<time datetime="{{ post.date_iso }}">{{ post.date }}</time>
<span class="mx-1">&middot;</span>
<a href="{{ post.author.url }}" class="text-body-secondary text-decoration-none">{{ post.author.name }}</a>
</div>
<p class="card-text">{{ post.excerpt|raw }}</p>
<a href="{{ post.url }}" class="btn btn-outline-primary btn-sm">
{{ post.read_more }}
</a>
</div>
</article>

Some files were not shown because too many files have changed in this diff Show More