You've already forked wp-bootstrap
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c8526d2a5 | |||
| 02689f687f | |||
| 17728e81d9 | |||
| ea2ccef5de | |||
| e607382e11 | |||
| 3165e60639 | |||
| 9904bf508a | |||
| 77778860ab | |||
| 0902c5e1a5 | |||
| 1a0a1fa63a | |||
| 576922160e | |||
| 89afa00678 | |||
| 876be4a041 | |||
| 59b79d23df | |||
| e7decbe96b | |||
| 815f6fa19e | |||
| b285d75878 |
@@ -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
|
||||
@@ -120,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"
|
||||
@@ -187,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: |
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,8 +22,14 @@ npm-debug.log
|
||||
*.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
4
.markdownlint.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"MD024": false,
|
||||
"MD013": false
|
||||
}
|
||||
144
CHANGELOG.md
144
CHANGELOG.md
@@ -2,6 +2,150 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.1.3] - 2026-03-07
|
||||
|
||||
### Security
|
||||
|
||||
- **Template output escaping**: Added `|esc_url` filter to all unescaped URL outputs across 8 Twig template partials — `header.html.twig`, `header-offcanvas.html.twig`, `header-transparent.html.twig`, `header-centered.html.twig`, `footer.html.twig`, `footer-columns.html.twig`, `search-form.html.twig`, `comment-item.html.twig`. Covers `site.url`, `item.url`, `child.url`, `user.account_url`, `comment.author_url`, and `comment.edit_url`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Twig escape filters** (`TwigService.php`): Registered `esc_html`, `esc_attr`, and `esc_url` as Twig filters with `['is_safe' => ['html']]` to prevent double-encoding. Complements existing `wpautop` and `wp_kses_post` filters.
|
||||
|
||||
## [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 `&`. When passed to Twig with autoescape enabled, the `&` in `&` was escaped again to `&#038;`, rendering as literal `&` in the browser (e.g. "Bewerbungen & 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 `&` → `&`. 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
|
||||
|
||||
312
CLAUDE.md
312
CLAUDE.md
@@ -34,7 +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.
|
||||
|
||||
Current version is **v1.0.2**. See `PLAN.md` for details.
|
||||
Current version is **v1.1.2**. See `PLAN.md` for details.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -77,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
|
||||
|
||||
@@ -193,6 +234,273 @@ Build steps (in order):
|
||||
|
||||
## 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. `&` → `&`). `ContextBuilder` passed these pre-encoded strings to Twig as template variables. Twig's autoescape then re-encoded the `&` in `&` to `&#038;`, which browsers rendered as the literal text `&` 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 (`—`, `“`). 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;` instead of `&`.
|
||||
- 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.
|
||||
|
||||
62
README.md
62
README.md
@@ -10,14 +10,16 @@ A modern WordPress Block Theme built from scratch with Bootstrap 5. Features res
|
||||
- **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 Styles** -- 17 custom styles mapping Bootstrap components to WordPress blocks
|
||||
- **Custom Templates** -- Landing (no header/footer), full-width, hero, sidebar page templates
|
||||
- **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 `en_US`, `de_CH`, and `fr_FR` translations
|
||||
- **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
|
||||
@@ -60,20 +62,41 @@ Activate the theme in **Appearance > Themes** in the WordPress admin.
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm run build` | Full production build (copy JS, compile SCSS, minify CSS) |
|
||||
| `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. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
|
||||
2. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
|
||||
3. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
|
||||
4. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
|
||||
5. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
|
||||
6. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
|
||||
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
|
||||
|
||||
@@ -84,7 +107,7 @@ 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`. FSE templates remain untouched for the WordPress admin editor.
|
||||
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
|
||||
|
||||
@@ -98,6 +121,8 @@ WordPress style variation colors are bridged to Bootstrap CSS custom properties
|
||||
| `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
|
||||
|
||||
@@ -110,7 +135,15 @@ If no menu is assigned, the primary location falls back to listing published pag
|
||||
|
||||
### 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.
|
||||
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
|
||||
|
||||
@@ -118,6 +151,7 @@ The theme registers a **Sidebar** widget area. When widgets are assigned via **A
|
||||
wp-bootstrap/
|
||||
+-- assets/ Compiled CSS, JS, fonts
|
||||
+-- inc/
|
||||
| +-- Block/ BlockRenderer, WidgetRenderer
|
||||
| +-- Template/ TemplateController, ContextBuilder, NavWalker
|
||||
| +-- Twig/ TwigService singleton
|
||||
+-- languages/ Translation files (.pot, .po)
|
||||
@@ -127,6 +161,9 @@ wp-bootstrap/
|
||||
| +-- 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
|
||||
@@ -145,6 +182,7 @@ wp-bootstrap/
|
||||
- **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
|
||||
|
||||
|
||||
@@ -2443,7 +2443,7 @@ textarea.form-control-lg {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-check[disabled] + .btn, .btn-check:disabled + .btn {
|
||||
.btn-check[disabled] + .btn, .widget .search-form .btn-check[disabled] + .search-submit, .btn-check:disabled + .btn, .widget .search-form .btn-check:disabled + .search-submit {
|
||||
pointer-events: none;
|
||||
filter: none;
|
||||
opacity: 0.65;
|
||||
@@ -2642,11 +2642,11 @@ textarea.form-control-lg {
|
||||
.input-group > .form-floating:focus-within {
|
||||
z-index: 5;
|
||||
}
|
||||
.input-group .btn {
|
||||
.input-group .btn, .input-group .widget .search-form .search-submit, .widget .search-form .input-group .search-submit {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.input-group .btn:focus {
|
||||
.input-group .btn:focus, .input-group .widget .search-form .search-submit:focus, .widget .search-form .input-group .search-submit:focus {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -2668,7 +2668,8 @@ textarea.form-control-lg {
|
||||
.input-group-lg > .form-control,
|
||||
.input-group-lg > .form-select,
|
||||
.input-group-lg > .input-group-text,
|
||||
.input-group-lg > .btn {
|
||||
.input-group-lg > .btn,
|
||||
.widget .search-form .input-group-lg > .search-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.25rem;
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
@@ -2677,7 +2678,8 @@ textarea.form-control-lg {
|
||||
.input-group-sm > .form-control,
|
||||
.input-group-sm > .form-select,
|
||||
.input-group-sm > .input-group-text,
|
||||
.input-group-sm > .btn {
|
||||
.input-group-sm > .btn,
|
||||
.widget .search-form .input-group-sm > .search-submit {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
@@ -2893,7 +2895,7 @@ textarea.form-control-lg {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
.btn, .widget .search-form .search-submit {
|
||||
--bs-btn-padding-x: 0.75rem;
|
||||
--bs-btn-padding-y: 0.375rem;
|
||||
--bs-btn-font-family: ;
|
||||
@@ -2927,44 +2929,44 @@ textarea.form-control-lg {
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn {
|
||||
.btn, .widget .search-form .search-submit {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.btn:hover {
|
||||
.btn:hover, .widget .search-form .search-submit:hover {
|
||||
color: var(--bs-btn-hover-color);
|
||||
background-color: var(--bs-btn-hover-bg);
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
}
|
||||
.btn-check + .btn:hover {
|
||||
.btn-check + .btn:hover, .widget .search-form .btn-check + .search-submit:hover {
|
||||
color: var(--bs-btn-color);
|
||||
background-color: var(--bs-btn-bg);
|
||||
border-color: var(--bs-btn-border-color);
|
||||
}
|
||||
.btn:focus-visible {
|
||||
.btn:focus-visible, .widget .search-form .search-submit:focus-visible {
|
||||
color: var(--bs-btn-hover-color);
|
||||
background-color: var(--bs-btn-hover-bg);
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
outline: 0;
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn-check:focus-visible + .btn {
|
||||
.btn-check:focus-visible + .btn, .widget .search-form .btn-check:focus-visible + .search-submit {
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
outline: 0;
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {
|
||||
.btn-check:checked + .btn, .widget .search-form .btn-check:checked + .search-submit, :not(.btn-check) + .btn:active, .widget .search-form :not(.btn-check) + .search-submit:active, .btn:first-child:active, .widget .search-form .search-submit:first-child:active, .btn.active, .widget .search-form .active.search-submit, .btn.show, .widget .search-form .show.search-submit {
|
||||
color: var(--bs-btn-active-color);
|
||||
background-color: var(--bs-btn-active-bg);
|
||||
border-color: var(--bs-btn-active-border-color);
|
||||
}
|
||||
.btn-check:checked + .btn:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .btn:first-child:active:focus-visible, .btn.active:focus-visible, .btn.show:focus-visible {
|
||||
.btn-check:checked + .btn:focus-visible, .widget .search-form .btn-check:checked + .search-submit:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .widget .search-form :not(.btn-check) + .search-submit:active:focus-visible, .btn:first-child:active:focus-visible, .widget .search-form .search-submit:first-child:active:focus-visible, .btn.active:focus-visible, .widget .search-form .active.search-submit:focus-visible, .btn.show:focus-visible, .widget .search-form .show.search-submit:focus-visible {
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn-check:checked:focus-visible + .btn {
|
||||
.btn-check:checked:focus-visible + .btn, .widget .search-form .btn-check:checked:focus-visible + .search-submit {
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn:disabled, .btn.disabled, fieldset:disabled .btn {
|
||||
.btn:disabled, .widget .search-form .search-submit:disabled, .btn.disabled, .widget .search-form .disabled.search-submit, fieldset:disabled .btn, fieldset:disabled .widget .search-form .search-submit, .widget .search-form fieldset:disabled .search-submit {
|
||||
color: var(--bs-btn-disabled-color);
|
||||
pointer-events: none;
|
||||
background-color: var(--bs-btn-disabled-bg);
|
||||
@@ -2972,7 +2974,7 @@ textarea.form-control-lg {
|
||||
opacity: var(--bs-btn-disabled-opacity);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.btn-primary, .widget .search-form .search-submit {
|
||||
--bs-btn-color: #FFFFFF;
|
||||
--bs-btn-bg: #0d6efd;
|
||||
--bs-btn-border-color: #0d6efd;
|
||||
@@ -3266,14 +3268,14 @@ textarea.form-control-lg {
|
||||
color: var(--bs-btn-hover-color);
|
||||
}
|
||||
|
||||
.btn-lg, .btn-group-lg > .btn {
|
||||
.btn-lg, .btn-group-lg > .btn, .widget .search-form .btn-group-lg > .search-submit {
|
||||
--bs-btn-padding-y: 0.5rem;
|
||||
--bs-btn-padding-x: 1rem;
|
||||
--bs-btn-font-size: 1.25rem;
|
||||
--bs-btn-border-radius: var(--bs-border-radius-lg);
|
||||
}
|
||||
|
||||
.btn-sm, .btn-group-sm > .btn {
|
||||
.btn-sm, .btn-group-sm > .btn, .widget .search-form .btn-group-sm > .search-submit {
|
||||
--bs-btn-padding-y: 0.25rem;
|
||||
--bs-btn-padding-x: 0.5rem;
|
||||
--bs-btn-font-size: 0.875rem;
|
||||
@@ -3640,23 +3642,35 @@ textarea.form-control-lg {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btn-group > .btn,
|
||||
.btn-group-vertical > .btn {
|
||||
.btn-group > .btn, .widget .search-form .btn-group > .search-submit,
|
||||
.btn-group-vertical > .btn,
|
||||
.widget .search-form .btn-group-vertical > .search-submit {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.btn-group > .btn-check:checked + .btn,
|
||||
.btn-group > .btn-check:checked + .btn, .widget .search-form .btn-group > .btn-check:checked + .search-submit,
|
||||
.btn-group > .btn-check:focus + .btn,
|
||||
.widget .search-form .btn-group > .btn-check:focus + .search-submit,
|
||||
.btn-group > .btn:hover,
|
||||
.widget .search-form .btn-group > .search-submit:hover,
|
||||
.btn-group > .btn:focus,
|
||||
.widget .search-form .btn-group > .search-submit:focus,
|
||||
.btn-group > .btn:active,
|
||||
.widget .search-form .btn-group > .search-submit:active,
|
||||
.btn-group > .btn.active,
|
||||
.widget .search-form .btn-group > .active.search-submit,
|
||||
.btn-group-vertical > .btn-check:checked + .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-check:checked + .search-submit,
|
||||
.btn-group-vertical > .btn-check:focus + .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-check:focus + .search-submit,
|
||||
.btn-group-vertical > .btn:hover,
|
||||
.widget .search-form .btn-group-vertical > .search-submit:hover,
|
||||
.btn-group-vertical > .btn:focus,
|
||||
.widget .search-form .btn-group-vertical > .search-submit:focus,
|
||||
.btn-group-vertical > .btn:active,
|
||||
.btn-group-vertical > .btn.active {
|
||||
.widget .search-form .btn-group-vertical > .search-submit:active,
|
||||
.btn-group-vertical > .btn.active,
|
||||
.widget .search-form .btn-group-vertical > .active.search-submit {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -3672,19 +3686,23 @@ textarea.form-control-lg {
|
||||
.btn-group {
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
.btn-group > :not(.btn-check:first-child) + .btn,
|
||||
.btn-group > :not(.btn-check:first-child) + .btn, .widget .search-form .btn-group > :not(.btn-check:first-child) + .search-submit,
|
||||
.btn-group > .btn-group:not(:first-child) {
|
||||
margin-left: calc(-1 * var(--bs-border-width));
|
||||
}
|
||||
.btn-group > .btn:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group > .btn:not(:last-child):not(.dropdown-toggle), .widget .search-form .btn-group > .search-submit:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group > .btn.dropdown-toggle-split:first-child,
|
||||
.btn-group > .btn-group:not(:last-child) > .btn {
|
||||
.widget .search-form .btn-group > .dropdown-toggle-split.search-submit:first-child,
|
||||
.btn-group > .btn-group:not(:last-child) > .btn,
|
||||
.widget .search-form .btn-group > .btn-group:not(:last-child) > .search-submit {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.btn-group > .btn:nth-child(n+3),
|
||||
.btn-group > .btn:nth-child(n+3), .widget .search-form .btn-group > .search-submit:nth-child(n+3),
|
||||
.btn-group > :not(.btn-check) + .btn,
|
||||
.btn-group > .btn-group:not(:first-child) > .btn {
|
||||
.widget .search-form .btn-group > :not(.btn-check) + .search-submit,
|
||||
.btn-group > .btn-group:not(:first-child) > .btn,
|
||||
.widget .search-form .btn-group > .btn-group:not(:first-child) > .search-submit {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -3700,12 +3718,12 @@ textarea.form-control-lg {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {
|
||||
.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split, .widget .search-form .btn-group-sm > .search-submit + .dropdown-toggle-split {
|
||||
padding-right: 0.375rem;
|
||||
padding-left: 0.375rem;
|
||||
}
|
||||
|
||||
.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {
|
||||
.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split, .widget .search-form .btn-group-lg > .search-submit + .dropdown-toggle-split {
|
||||
padding-right: 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
@@ -3715,22 +3733,25 @@ textarea.form-control-lg {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-group-vertical > .btn,
|
||||
.btn-group-vertical > .btn, .widget .search-form .btn-group-vertical > .search-submit,
|
||||
.btn-group-vertical > .btn-group {
|
||||
width: 100%;
|
||||
}
|
||||
.btn-group-vertical > .btn:not(:first-child),
|
||||
.btn-group-vertical > .btn:not(:first-child), .widget .search-form .btn-group-vertical > .search-submit:not(:first-child),
|
||||
.btn-group-vertical > .btn-group:not(:first-child) {
|
||||
margin-top: calc(-1 * var(--bs-border-width));
|
||||
}
|
||||
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group-vertical > .btn-group:not(:last-child) > .btn {
|
||||
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), .widget .search-form .btn-group-vertical > .search-submit:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group-vertical > .btn-group:not(:last-child) > .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-group:not(:last-child) > .search-submit {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.btn-group-vertical > .btn:nth-child(n+3),
|
||||
.btn-group-vertical > .btn:nth-child(n+3), .widget .search-form .btn-group-vertical > .search-submit:nth-child(n+3),
|
||||
.btn-group-vertical > :not(.btn-check) + .btn,
|
||||
.btn-group-vertical > .btn-group:not(:first-child) > .btn {
|
||||
.widget .search-form .btn-group-vertical > :not(.btn-check) + .search-submit,
|
||||
.btn-group-vertical > .btn-group:not(:first-child) > .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-group:not(:first-child) > .search-submit {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
@@ -4769,7 +4790,7 @@ textarea.form-control-lg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn .badge {
|
||||
.btn .badge, .widget .search-form .search-submit .badge {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
@@ -6718,7 +6739,7 @@ textarea.form-control-lg {
|
||||
background-color: currentcolor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.placeholder.btn::before {
|
||||
.placeholder.btn::before, .widget .search-form .placeholder.search-submit::before {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
}
|
||||
@@ -20227,6 +20248,127 @@ h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6, blockquote, caption, figca
|
||||
content: "爛";
|
||||
}
|
||||
|
||||
.widget .card-title,
|
||||
.widget .wp-block-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.widget ul,
|
||||
.widget ol {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.widget ul li,
|
||||
.widget ol li {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
|
||||
}
|
||||
.widget ul li:last-child,
|
||||
.widget ol li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.widget ul li a,
|
||||
.widget ol li a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.widget ul li a:hover,
|
||||
.widget ol li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
.widget .card-body > ul li:first-child,
|
||||
.widget .card-body > ol li:first-child,
|
||||
.widget .card-body > nav > ul li:first-child,
|
||||
.widget .card-body > .wp-block-group > ul li:first-child,
|
||||
.widget .card-body > .wp-block-group > ol li:first-child {
|
||||
border-top: var(--bs-border-width) solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.widget select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
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;
|
||||
}
|
||||
.widget select:focus {
|
||||
border-color: rgb(134, 182.5, 254);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.widget .search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.widget .search-form .search-field {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
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);
|
||||
}
|
||||
.widget .search-form .search-field:focus {
|
||||
border-color: rgb(134, 182.5, 254);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
.widget .wp-block-search .wp-block-search__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget .tagcloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.widget .tagcloud a {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem !important;
|
||||
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;
|
||||
}
|
||||
.widget .tagcloud a:hover {
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.widget .post-date {
|
||||
display: block;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.widget .recentcomments {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.wp-bootstrap-dark-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
File diff suppressed because one or more lines are too long
4
assets/css/style.min.css
vendored
4
assets/css/style.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -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';
|
||||
|
||||
@@ -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
2038
composer.lock
generated
File diff suppressed because it is too large
Load Diff
112
functions.php
112
functions.php
@@ -83,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',
|
||||
@@ -175,6 +182,17 @@ add_action( 'wp_enqueue_scripts', 'wp_bootstrap_rtl_styles', 20 );
|
||||
*/
|
||||
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' ) );
|
||||
@@ -205,10 +223,12 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -280,6 +300,9 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
. '[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 );
|
||||
@@ -287,6 +310,34 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
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.
|
||||
*
|
||||
@@ -345,17 +396,12 @@ endif;
|
||||
* @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( $hex ) {
|
||||
$hex = ltrim( $hex, '#' );
|
||||
if ( strlen( $hex ) === 3 ) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if ( strlen( $hex ) !== 6 ) {
|
||||
function wp_bootstrap_hex_to_rgb( string $hex ): string {
|
||||
$rgb = wp_bootstrap_hex_to_rgb_array( $hex );
|
||||
if ( ! $rgb ) {
|
||||
return '';
|
||||
}
|
||||
return hexdec( substr( $hex, 0, 2 ) ) . ','
|
||||
. hexdec( substr( $hex, 2, 2 ) ) . ','
|
||||
. hexdec( substr( $hex, 4, 2 ) );
|
||||
return implode( ',', $rgb );
|
||||
}
|
||||
endif;
|
||||
|
||||
@@ -370,7 +416,7 @@ endif;
|
||||
* @return string Resulting hex color.
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_mix_hex' ) ) :
|
||||
function wp_bootstrap_mix_hex( $color1, $color2, $weight ) {
|
||||
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 ) {
|
||||
@@ -392,12 +438,12 @@ endif;
|
||||
* @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( $hex ) {
|
||||
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 ) {
|
||||
if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) {
|
||||
return false;
|
||||
}
|
||||
return array(
|
||||
@@ -417,7 +463,7 @@ endif;
|
||||
* @return float Relative luminance.
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_relative_luminance' ) ) :
|
||||
function wp_bootstrap_relative_luminance( $hex ) {
|
||||
function wp_bootstrap_relative_luminance( string $hex ): float {
|
||||
$rgb = wp_bootstrap_hex_to_rgb_array( $hex );
|
||||
if ( ! $rgb ) {
|
||||
return 0.0;
|
||||
@@ -724,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' );
|
||||
@@ -757,6 +809,40 @@ if ( ! function_exists( 'wp_bootstrap_init_templates' ) ) :
|
||||
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.
|
||||
*
|
||||
|
||||
303
inc/Block/BlockRenderer.php
Normal file
303
inc/Block/BlockRenderer.php
Normal 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();
|
||||
}
|
||||
}
|
||||
112
inc/Block/WidgetRenderer.php
Normal file
112
inc/Block/WidgetRenderer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ class ContextBuilder
|
||||
'layout' => 'default',
|
||||
'header_variant' => $this->getHeaderVariant(),
|
||||
'footer_variant' => $this->getFooterVariant(),
|
||||
'user' => $this->getUserData(),
|
||||
];
|
||||
|
||||
if (is_singular()) {
|
||||
@@ -57,7 +58,9 @@ class ContextBuilder
|
||||
$context['search_query'] = get_search_query();
|
||||
}
|
||||
|
||||
// Sidebar layout detection.
|
||||
// Sidebar: determine once whether the current page needs sidebar data.
|
||||
$needsSidebar = false;
|
||||
|
||||
if (is_home()) {
|
||||
$pageId = (int) get_option('page_for_posts');
|
||||
if ($pageId) {
|
||||
@@ -66,6 +69,14 @@ class ContextBuilder
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -85,6 +96,28 @@ class ContextBuilder
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -145,7 +178,7 @@ class ContextBuilder
|
||||
|
||||
return [
|
||||
'id' => $post->ID,
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'content' => apply_filters('the_content', get_the_content()),
|
||||
'excerpt' => get_the_excerpt(),
|
||||
@@ -176,7 +209,7 @@ class ContextBuilder
|
||||
$wp_query->the_post();
|
||||
$posts[] = [
|
||||
'id' => get_the_ID(),
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'excerpt' => get_the_excerpt(),
|
||||
'date' => get_the_date(),
|
||||
@@ -237,8 +270,10 @@ class ContextBuilder
|
||||
private function getArchiveData(): array
|
||||
{
|
||||
return [
|
||||
'title' => get_the_archive_title(),
|
||||
'description' => get_the_archive_description(),
|
||||
// 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()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -271,20 +306,27 @@ class ContextBuilder
|
||||
|
||||
/**
|
||||
* 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
|
||||
private function buildCommentTree(array $comments, int $parentId = 0, ?array $index = null): array
|
||||
{
|
||||
$tree = [];
|
||||
|
||||
if ($index === null) {
|
||||
$index = [];
|
||||
foreach ($comments as $comment) {
|
||||
if ((int) $comment->comment_parent !== $parentId) {
|
||||
continue;
|
||||
$parent = (int) $comment->comment_parent;
|
||||
$index[$parent][] = $comment;
|
||||
}
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
|
||||
foreach ($index[$parentId] ?? [] as $comment) {
|
||||
$tree[] = [
|
||||
'id' => (int) $comment->comment_ID,
|
||||
'author' => $comment->comment_author,
|
||||
'author_url' => $comment->comment_author_url,
|
||||
'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),
|
||||
@@ -296,7 +338,7 @@ class ContextBuilder
|
||||
'depth' => 1,
|
||||
'max_depth' => get_option('thread_comments_depth', 5),
|
||||
], $comment),
|
||||
'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID),
|
||||
'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID, $index),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -337,14 +379,14 @@ class ContextBuilder
|
||||
|
||||
if ($prev) {
|
||||
$navigation['previous'] = [
|
||||
'title' => get_the_title($prev),
|
||||
'title' => wp_specialchars_decode( get_the_title($prev) ),
|
||||
'url' => get_permalink($prev),
|
||||
];
|
||||
}
|
||||
|
||||
if ($next) {
|
||||
$navigation['next'] = [
|
||||
'title' => get_the_title($next),
|
||||
'title' => wp_specialchars_decode( get_the_title($next) ),
|
||||
'url' => get_permalink($next),
|
||||
];
|
||||
}
|
||||
@@ -372,7 +414,7 @@ class ContextBuilder
|
||||
$query->the_post();
|
||||
$posts[] = [
|
||||
'id' => get_the_ID(),
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'date' => get_the_date(),
|
||||
'date_iso' => get_the_date('c'),
|
||||
@@ -412,9 +454,17 @@ class ContextBuilder
|
||||
|
||||
/**
|
||||
* 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',
|
||||
@@ -426,7 +476,7 @@ class ContextBuilder
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$posts[] = [
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'date' => get_the_date(),
|
||||
];
|
||||
@@ -434,14 +484,23 @@ class ContextBuilder
|
||||
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',
|
||||
@@ -461,6 +520,7 @@ class ContextBuilder
|
||||
];
|
||||
}
|
||||
|
||||
set_transient($transient_key, $items, HOUR_IN_SECONDS);
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ class TemplateController
|
||||
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;
|
||||
@@ -78,7 +83,11 @@ class TemplateController
|
||||
}
|
||||
|
||||
if (is_singular('post')) {
|
||||
return 'pages/single.html.twig';
|
||||
$slug = get_page_template_slug();
|
||||
return match ($slug) {
|
||||
'page-full-width' => 'pages/single.html.twig',
|
||||
default => 'pages/single-sidebar.html.twig',
|
||||
};
|
||||
}
|
||||
|
||||
if (is_page()) {
|
||||
|
||||
@@ -27,7 +27,7 @@ class TwigService
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => WP_DEBUG ? false : $cacheDir,
|
||||
'debug' => WP_DEBUG,
|
||||
'auto_reload' => true,
|
||||
'auto_reload' => WP_DEBUG,
|
||||
]);
|
||||
|
||||
$this->registerWordPressFunctions();
|
||||
@@ -73,10 +73,12 @@ class TwigService
|
||||
return _n($single, $plural, $number, $domain);
|
||||
}));
|
||||
|
||||
// Escaping functions.
|
||||
$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'));
|
||||
// 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;).
|
||||
$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 {
|
||||
@@ -132,10 +134,21 @@ class TwigService
|
||||
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
|
||||
@@ -152,5 +165,10 @@ class TwigService
|
||||
{
|
||||
$this->twig->addFilter(new TwigFilter('wpautop', 'wpautop', ['is_safe' => ['html']]));
|
||||
$this->twig->addFilter(new TwigFilter('wp_kses_post', 'wp_kses_post', ['is_safe' => ['html']]));
|
||||
|
||||
// Escaping filters — same functions registered above, but as filters for |esc_html syntax.
|
||||
$this->twig->addFilter(new TwigFilter('esc_html', 'esc_html', ['is_safe' => ['html']]));
|
||||
$this->twig->addFilter(new TwigFilter('esc_attr', 'esc_attr', ['is_safe' => ['html']]));
|
||||
$this->twig->addFilter(new TwigFilter('esc_url', 'esc_url', ['is_safe' => ['html']]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,919 +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.3.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
|
||||
#: patterns/footer-columns.php
|
||||
msgid "Powered by %s"
|
||||
msgstr "Betrieben mit %s"
|
||||
|
||||
#: patterns/footer.php
|
||||
#: patterns/footer-columns.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
|
||||
#: patterns/page-services.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
|
||||
#: patterns/page-services.php
|
||||
#: patterns/page-contact.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
|
||||
#: patterns/page-contact.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
|
||||
#: patterns/page-contact.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
|
||||
#: patterns/page-contact.php
|
||||
msgid "Email"
|
||||
msgstr "E-Mail"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
#: patterns/page-contact.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
|
||||
#: patterns/page-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 "Primary Navigation"
|
||||
msgstr "Primäre Navigation"
|
||||
|
||||
#: functions.php
|
||||
msgid "Footer Navigation"
|
||||
msgstr "Fusszeilen-Navigation"
|
||||
|
||||
#: 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"
|
||||
|
||||
#: functions.php
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: functions.php
|
||||
msgid "Website"
|
||||
msgstr "Website"
|
||||
|
||||
#: functions.php
|
||||
msgid "Save my name, email, and website in this browser for the next time I comment."
|
||||
msgstr "Meinen Namen, meine E-Mail-Adresse und meine Website in diesem Browser für meinen nächsten Kommentar speichern."
|
||||
|
||||
#: functions.php
|
||||
msgid "Bootstrap Layout"
|
||||
msgstr "Bootstrap-Layout"
|
||||
|
||||
#: functions.php
|
||||
msgid "Bootstrap Components"
|
||||
msgstr "Bootstrap-Komponenten"
|
||||
|
||||
#: functions.php
|
||||
msgid "Bootstrap Navigation"
|
||||
msgstr "Bootstrap-Navigation"
|
||||
|
||||
#: functions.php
|
||||
msgid "Layout"
|
||||
msgstr "Layout"
|
||||
|
||||
#: functions.php
|
||||
msgid "Layout building blocks for page structure."
|
||||
msgstr "Layout-Bausteine für die Seitenstruktur."
|
||||
|
||||
#: functions.php
|
||||
msgid "Components"
|
||||
msgstr "Komponenten"
|
||||
|
||||
#: functions.php
|
||||
msgid "Reusable Bootstrap component patterns."
|
||||
msgstr "Wiederverwendbare Bootstrap-Komponentenvorlagen."
|
||||
|
||||
#: functions.php
|
||||
#: patterns/footer-columns.php
|
||||
msgid "Navigation"
|
||||
msgstr "Navigation"
|
||||
|
||||
#: functions.php
|
||||
msgid "Navigation and header patterns."
|
||||
msgstr "Navigations- und Kopfzeilenvorlagen."
|
||||
|
||||
#: patterns/layout-container.php
|
||||
msgid "Heading goes here"
|
||||
msgstr "Überschrift hier eingeben"
|
||||
|
||||
#: patterns/layout-container.php
|
||||
msgid "This is a content container with constrained width and comfortable padding. Replace this text with your own content."
|
||||
msgstr "Dies ist ein Inhaltscontainer mit begrenzter Breite und komfortablem Innenabstand. Ersetzen Sie diesen Text durch Ihren eigenen Inhalt."
|
||||
|
||||
#: patterns/layout-2-col.php
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Column One"
|
||||
msgstr "Spalte Eins"
|
||||
|
||||
#: patterns/layout-2-col.php
|
||||
msgid "Add your content here. This column takes up half the available width on larger screens and stacks on mobile devices."
|
||||
msgstr "Fügen Sie hier Ihren Inhalt ein. Diese Spalte nimmt auf grösseren Bildschirmen die Hälfte der verfügbaren Breite ein und wird auf Mobilgeräten gestapelt."
|
||||
|
||||
#: patterns/layout-2-col.php
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Column Two"
|
||||
msgstr "Spalte Zwei"
|
||||
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Column Three"
|
||||
msgstr "Spalte Drei"
|
||||
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Add your content here. This column takes up one third of the available width on larger screens."
|
||||
msgstr "Fügen Sie hier Ihren Inhalt ein. Diese Spalte nimmt auf grösseren Bildschirmen ein Drittel der verfügbaren Breite ein."
|
||||
|
||||
#: patterns/layout-full-width-section.php
|
||||
msgid "Full Width Section Heading"
|
||||
msgstr "Überschrift des Vollbreiten-Abschnitts"
|
||||
|
||||
#: patterns/layout-full-width-section.php
|
||||
msgid "This full-width section stands out with a colored background. Use it to highlight important content, announcements, or calls to action."
|
||||
msgstr "Dieser Vollbreiten-Abschnitt fällt durch einen farbigen Hintergrund auf. Verwenden Sie ihn, um wichtige Inhalte, Ankündigungen oder Handlungsaufforderungen hervorzuheben."
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Card One"
|
||||
msgstr "Karte Eins"
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Card Two"
|
||||
msgstr "Karte Zwei"
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Card Three"
|
||||
msgstr "Karte Drei"
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Add a short description for this card. Cards are a great way to organize and present related content."
|
||||
msgstr "Fügen Sie eine kurze Beschreibung für diese Karte hinzu. Karten sind eine hervorragende Möglichkeit, zusammengehörige Inhalte zu organisieren und zu präsentieren."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion"
|
||||
msgstr "Akkordeon"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Click on each item to expand and reveal its content."
|
||||
msgstr "Klicken Sie auf jedes Element, um es aufzuklappen und den Inhalt anzuzeigen."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion Item One"
|
||||
msgstr "Akkordeon-Element Eins"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "This is the content for the first accordion item. You can add any blocks inside this details element to create rich, expandable content sections."
|
||||
msgstr "Dies ist der Inhalt des ersten Akkordeon-Elements. Sie können beliebige Blöcke in dieses Details-Element einfügen, um reichhaltige, aufklappbare Inhaltsabschnitte zu erstellen."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion Item Two"
|
||||
msgstr "Akkordeon-Element Zwei"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "This is the content for the second accordion item. Details blocks are a native HTML element that provide toggle functionality without JavaScript."
|
||||
msgstr "Dies ist der Inhalt des zweiten Akkordeon-Elements. Details-Blöcke sind native HTML-Elemente, die Aufklappfunktionalität ohne JavaScript bieten."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion Item Three"
|
||||
msgstr "Akkordeon-Element Drei"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "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."
|
||||
msgstr "Dies ist der Inhalt des dritten Akkordeon-Elements. Verwenden Sie Akkordeons, um häufig gestellte Fragen, Funktionslisten oder andere Inhalte zu organisieren, die von schrittweiser Offenlegung profitieren."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "About Us"
|
||||
msgstr "Über uns"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Learn more about who we are, what we do, and the people behind our mission."
|
||||
msgstr "Erfahren Sie mehr darüber, wer wir sind, was wir tun und wer hinter unserer Mission steht."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Our Story"
|
||||
msgstr "Unsere Geschichte"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "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."
|
||||
msgstr "Gegründet mit einer Leidenschaft für Innovation und Exzellenz, begann unsere Reise mit einer einfachen Idee: bedeutungsvolle Lösungen zu schaffen, die einen echten Unterschied machen. Im Laufe der Jahre sind wir von einem kleinen Team zu einer engagierten Gruppe von Fachleuten gewachsen, die sich der Erbringung herausragender Ergebnisse verschrieben haben."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "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."
|
||||
msgstr "Heute stossen wir weiterhin Grenzen und hinterfragen Konventionen. Unser Ansatz verbindet kreatives Denken mit bewährten Methoden und stellt sicher, dass jedes Projekt, das wir übernehmen, den höchsten Qualitäts- und Handwerksstandards entspricht."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Our Team"
|
||||
msgstr "Unser Team"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Meet the people who make it all happen."
|
||||
msgstr "Lernen Sie die Menschen kennen, die alles möglich machen."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Jane Doe"
|
||||
msgstr "Jane Doe"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Founder & CEO"
|
||||
msgstr "Gründerin & CEO"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "John Smith"
|
||||
msgstr "John Smith"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Lead Developer"
|
||||
msgstr "Leitender Entwickler"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Emily Johnson"
|
||||
msgstr "Emily Johnson"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Creative Director"
|
||||
msgstr "Kreativdirektorin"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Our Services"
|
||||
msgstr "Unsere Dienstleistungen"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Professional solutions tailored to your needs."
|
||||
msgstr "Professionelle Lösungen, massgeschneidert auf Ihre Bedürfnisse."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "What We Offer"
|
||||
msgstr "Was wir anbieten"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "We provide a wide range of services to help your business grow and succeed."
|
||||
msgstr "Wir bieten ein breites Spektrum an Dienstleistungen, um Ihr Unternehmen beim Wachstum und Erfolg zu unterstützen."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Design"
|
||||
msgstr "Design"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Beautiful, user-centered designs that capture your brand identity and engage your audience across all platforms and devices."
|
||||
msgstr "Schöne, benutzerzentrierte Designs, die Ihre Markenidentität einfangen und Ihr Publikum auf allen Plattformen und Geräten ansprechen."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Development"
|
||||
msgstr "Entwicklung"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Robust, scalable web applications built with modern technologies and best practices to ensure performance and reliability."
|
||||
msgstr "Robuste, skalierbare Webanwendungen, erstellt mit modernen Technologien und bewährten Methoden, um Leistung und Zuverlässigkeit zu gewährleisten."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Strategy"
|
||||
msgstr "Strategie"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Data-driven strategies and consulting to help you achieve your business goals and stay ahead of the competition."
|
||||
msgstr "Datengetriebene Strategien und Beratung, um Ihnen zu helfen, Ihre Geschäftsziele zu erreichen und der Konkurrenz voraus zu sein."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Ready to take your project to the next level? Let us help you build something great."
|
||||
msgstr "Bereit, Ihr Projekt auf die nächste Stufe zu heben? Lassen Sie uns Ihnen helfen, etwas Grossartiges zu bauen."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "View Portfolio"
|
||||
msgstr "Portfolio ansehen"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "We would love to hear from you. Reach out to us anytime."
|
||||
msgstr "Wir freuen uns von Ihnen zu hören. Kontaktieren Sie uns jederzeit."
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Get in Touch"
|
||||
msgstr "Kontakt aufnehmen"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "123 Main Street, Suite 100, Anytown, ST 12345"
|
||||
msgstr "Hauptstrasse 123, Suite 100, 8000 Zürich"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "+1 (555) 123-4567"
|
||||
msgstr "+41 44 123 45 67"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Business Hours"
|
||||
msgstr "Öffnungszeiten"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Monday - Friday:"
|
||||
msgstr "Montag - Freitag:"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "9:00 AM - 6:00 PM"
|
||||
msgstr "09:00 - 18:00 Uhr"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Saturday:"
|
||||
msgstr "Samstag:"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "10:00 AM - 4:00 PM"
|
||||
msgstr "10:00 - 16:00 Uhr"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Sunday:"
|
||||
msgstr "Sonntag:"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Closed"
|
||||
msgstr "Geschlossen"
|
||||
|
||||
#. translators: Copyright notice. %1$s: Year, %2$s: Site title.
|
||||
#: patterns/footer-minimal.php
|
||||
msgid "© %1$s %2$s. All rights reserved."
|
||||
msgstr "© %1$s %2$s. Alle Rechte vorbehalten."
|
||||
|
||||
#: patterns/footer-columns.php
|
||||
msgid "A modern WordPress theme built with Bootstrap 5."
|
||||
msgstr "Ein modernes WordPress-Theme, erstellt mit Bootstrap 5."
|
||||
|
||||
#: patterns/footer-columns.php
|
||||
msgid "About"
|
||||
msgstr "Über"
|
||||
|
||||
#: patterns/footer-columns.php
|
||||
msgid "This theme is proudly built with Bootstrap 5 and WordPress Full Site Editing."
|
||||
msgstr "Dieses Theme wurde mit Stolz mit Bootstrap 5 und WordPress Full Site Editing erstellt."
|
||||
|
||||
#. translators: Copyright notice. %1$s: Year, %2$s: Site title.
|
||||
#: patterns/footer-columns.php
|
||||
msgid "© %1$s %2$s"
|
||||
msgstr "© %1$s %2$s"
|
||||
|
||||
#: views/base.html.twig
|
||||
msgid "Skip to main content"
|
||||
msgstr "Zum Hauptinhalt springen"
|
||||
|
||||
#: views/partials/header.html.twig
|
||||
#: views/partials/header-centered.html.twig
|
||||
#: views/partials/header-transparent.html.twig
|
||||
#: views/partials/header-offcanvas.html.twig
|
||||
msgid "Primary navigation"
|
||||
msgstr "Hauptnavigation"
|
||||
|
||||
#: views/partials/footer.html.twig
|
||||
#: views/partials/footer-columns.html.twig
|
||||
msgid "Footer navigation"
|
||||
msgstr "Fussnavigation"
|
||||
|
||||
#: functions.php
|
||||
msgid "Sidebar"
|
||||
msgstr "Seitenleiste"
|
||||
|
||||
#: functions.php
|
||||
msgid "Add widgets here to appear in the sidebar."
|
||||
msgstr "Widgets hier hinzufuegen, um sie in der Seitenleiste anzuzeigen."
|
||||
|
||||
#: views/partials/sidebar.html.twig
|
||||
msgid "Blog sidebar"
|
||||
msgstr "Blog-Seitenleiste"
|
||||
@@ -1,917 +0,0 @@
|
||||
# French translation for WP Bootstrap theme.
|
||||
# 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.3.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: Claude AI <noreply@anthropic.com>\n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\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 "Un theme WordPress Block moderne construit de zéro avec Bootstrap 5. Design réactif, prise en charge du mode sombre et compatibilité totale avec l'éditeur de site WordPress."
|
||||
|
||||
#: patterns/footer.php
|
||||
#: patterns/footer-columns.php
|
||||
msgid "Powered by %s"
|
||||
msgstr "Propulsé par %s"
|
||||
|
||||
#: patterns/footer.php
|
||||
#: patterns/footer-columns.php
|
||||
msgid "https://wordpress.org"
|
||||
msgstr "https://fr.wordpress.org"
|
||||
|
||||
#: patterns/hidden-404.php
|
||||
msgid "Page not found"
|
||||
msgstr "Page non trouvée"
|
||||
|
||||
#: 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 "La page que vous recherchez n'existe pas ou a été déplacée. Veuillez essayer de rechercher en utilisant le formulaire ci-dessous."
|
||||
|
||||
#: patterns/hidden-search.php
|
||||
#: patterns/hidden-sidebar.php
|
||||
msgid "Search"
|
||||
msgstr "Rechercher"
|
||||
|
||||
#: patterns/hidden-search.php
|
||||
#: patterns/hidden-sidebar.php
|
||||
msgid "Search..."
|
||||
msgstr "Rechercher..."
|
||||
|
||||
#: patterns/hidden-blog-heading.php
|
||||
msgid "Blog"
|
||||
msgstr "Blog"
|
||||
|
||||
#: patterns/comments.php
|
||||
msgid "Comments"
|
||||
msgstr "Commentaires"
|
||||
|
||||
#: patterns/post-navigation.php
|
||||
msgid "Previous"
|
||||
msgstr "Précédent"
|
||||
|
||||
#: patterns/post-navigation.php
|
||||
msgid "Next"
|
||||
msgstr "Suivant"
|
||||
|
||||
#: patterns/more-posts.php
|
||||
msgid "More posts"
|
||||
msgstr "Plus d'articles"
|
||||
|
||||
#: patterns/template-query-loop.php
|
||||
msgid "Read more"
|
||||
msgstr "Lire la suite"
|
||||
|
||||
#: patterns/template-query-loop.php
|
||||
msgid "No posts were found."
|
||||
msgstr "Aucun article n'a été trouvé."
|
||||
|
||||
#: patterns/hero-cover.php
|
||||
msgid "Build something amazing"
|
||||
msgstr "Créez quelque chose d'incroyable"
|
||||
|
||||
#: patterns/hero-cover.php
|
||||
msgid "Create modern, responsive websites with the power of Bootstrap 5 and the WordPress Site Editor."
|
||||
msgstr "Créez des sites web modernes et réactifs grâce à la puissance de Bootstrap 5 et de l'éditeur de site WordPress."
|
||||
|
||||
#: patterns/hero-cover.php
|
||||
#: patterns/hero-centered.php
|
||||
#: patterns/pricing-3-col.php
|
||||
#: patterns/page-services.php
|
||||
msgid "Get Started"
|
||||
msgstr "Commencer"
|
||||
|
||||
#: patterns/hero-split.php
|
||||
msgid "Modern design meets powerful features"
|
||||
msgstr "Un design moderne allié à des fonctionnalités puissantes"
|
||||
|
||||
#: patterns/hero-split.php
|
||||
msgid "A WordPress theme built from the ground up with Bootstrap 5 for a seamless editing and browsing experience."
|
||||
msgstr "Un thème WordPress construit de zéro avec Bootstrap 5 pour une expérience d'édition et de navigation fluide."
|
||||
|
||||
#: patterns/hero-split.php
|
||||
#: patterns/hero-centered.php
|
||||
#: patterns/text-about.php
|
||||
msgid "Learn More"
|
||||
msgstr "En savoir plus"
|
||||
|
||||
#: patterns/hero-split.php
|
||||
msgid "View Demo"
|
||||
msgstr "Voir la démo"
|
||||
|
||||
#: patterns/hero-split.php
|
||||
msgid "Hero image"
|
||||
msgstr "Image héros"
|
||||
|
||||
#: patterns/hero-centered.php
|
||||
msgid "Welcome to your new website"
|
||||
msgstr "Bienvenue sur votre nouveau site web"
|
||||
|
||||
#: patterns/hero-centered.php
|
||||
msgid "Start building beautiful, responsive pages with the full power of Bootstrap 5 and the WordPress block editor."
|
||||
msgstr "Commencez à créer de belles pages réactives avec toute la puissance de Bootstrap 5 et de l'éditeur de blocs WordPress."
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
#: functions.php
|
||||
msgid "Features"
|
||||
msgstr "Fonctionnalités"
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Everything you need to build a modern website."
|
||||
msgstr "Tout ce dont vous avez besoin pour créer un site web moderne."
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Responsive Design"
|
||||
msgstr "Design réactif"
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Your website looks great on every device, from mobile phones to large desktop screens."
|
||||
msgstr "Votre site web est superbe sur tous les appareils, des téléphones mobiles aux grands écrans de bureau."
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Easy Customization"
|
||||
msgstr "Personnalisation facile"
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Customize colors, fonts, and layouts using the WordPress Site Editor with no code required."
|
||||
msgstr "Personnalisez les couleurs, les polices et les mises en page avec l'éditeur de site WordPress, sans aucun code requis."
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Performance First"
|
||||
msgstr "La performance avant tout"
|
||||
|
||||
#: patterns/features-3-col.php
|
||||
msgid "Built with speed in mind. Optimized assets and clean code for lightning-fast page loads."
|
||||
msgstr "Conçu pour la vitesse. Ressources optimisées et code propre pour des chargements de pages ultra-rapides."
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Why choose us"
|
||||
msgstr "Pourquoi nous choisir"
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Bootstrap 5 Framework"
|
||||
msgstr "Framework Bootstrap 5"
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Built on the most popular CSS framework. Leverage a proven, well-documented design system."
|
||||
msgstr "Construit sur le framework CSS le plus populaire. Tirez parti d'un système de design éprouvé et bien documenté."
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Full Site Editing"
|
||||
msgstr "Édition complète du site"
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Edit every part of your site visually. Headers, footers, templates, and content are all customizable."
|
||||
msgstr "Modifiez chaque partie de votre site visuellement. En-têtes, pieds de page, modèles et contenu sont tous personnalisables."
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Dark Mode Support"
|
||||
msgstr "Prise en charge du mode sombre"
|
||||
|
||||
#: patterns/features-icon-list.php
|
||||
msgid "Built-in dark mode toggle that respects user preferences and persists across visits."
|
||||
msgstr "Bouton de mode sombre intégré qui respecte les préférences de l'utilisateur et persiste entre les visites."
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Feature illustration"
|
||||
msgstr "Illustration de fonctionnalité"
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Designed for modern workflows"
|
||||
msgstr "Conçu pour les flux de travail modernes"
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Streamline your development process with a theme that works the way you do."
|
||||
msgstr "Optimisez votre processus de développement avec un thème qui fonctionne comme vous."
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Block Patterns"
|
||||
msgstr "Modèles de blocs"
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Pre-built patterns for common page sections. Drop them in and customize to fit your needs."
|
||||
msgstr "Modèles préconçus pour les sections de page courantes. Insérez-les et personnalisez-les selon vos besoins."
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Style Variations"
|
||||
msgstr "Variations de style"
|
||||
|
||||
#: patterns/features-2-col-offset.php
|
||||
msgid "Switch between color schemes with a single click. Choose from multiple professionally designed palettes."
|
||||
msgstr "Basculez entre les palettes de couleurs en un seul clic. Choisissez parmi plusieurs palettes conçues professionnellement."
|
||||
|
||||
#: patterns/cta-banner.php
|
||||
msgid "Ready to get started?"
|
||||
msgstr "Prêt à commencer ?"
|
||||
|
||||
#: patterns/cta-banner.php
|
||||
msgid "Start building your website today with our powerful and flexible theme."
|
||||
msgstr "Commencez à créer votre site web dès aujourd'hui avec notre thème puissant et flexible."
|
||||
|
||||
#: patterns/cta-banner.php
|
||||
msgid "Start Now"
|
||||
msgstr "Commencer maintenant"
|
||||
|
||||
#: patterns/cta-newsletter.php
|
||||
msgid "Stay in the loop"
|
||||
msgstr "Restez informé"
|
||||
|
||||
#: patterns/cta-newsletter.php
|
||||
msgid "Subscribe to our newsletter for updates, tips, and exclusive content."
|
||||
msgstr "Abonnez-vous à notre newsletter pour des mises à jour, des conseils et du contenu exclusif."
|
||||
|
||||
#: patterns/cta-newsletter.php
|
||||
msgid "Enter your email address"
|
||||
msgstr "Entrez votre adresse courriel"
|
||||
|
||||
#: patterns/cta-newsletter.php
|
||||
msgid "Subscribe"
|
||||
msgstr "S'abonner"
|
||||
|
||||
#: patterns/testimonials-2-col.php
|
||||
msgid "What our clients say"
|
||||
msgstr "Ce que disent nos clients"
|
||||
|
||||
#: 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 "Ce thème a complètement transformé notre site web. L'intégration de Bootstrap rend incroyablement facile la création de pages d'aspect professionnel sans aucun code personnalisé."
|
||||
|
||||
#: patterns/testimonials-2-col.php
|
||||
msgid "Jane Doe, Web Designer"
|
||||
msgstr "Jane Doe, Webdesigneuse"
|
||||
|
||||
#: 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 "La prise en charge du mode sombre et les variations de style nous donnent la flexibilité dont nous avons besoin. Nos clients adorent pouvoir basculer entre les palettes de couleurs sans effort."
|
||||
|
||||
#: patterns/testimonials-2-col.php
|
||||
msgid "John Smith, Developer"
|
||||
msgstr "John Smith, Développeur"
|
||||
|
||||
#: patterns/testimonials-centered.php
|
||||
msgid "The best WordPress theme we have ever used. Clean code, beautiful design, and incredible flexibility."
|
||||
msgstr "Le meilleur thème WordPress que nous ayons jamais utilisé. Code propre, beau design et flexibilité incroyable."
|
||||
|
||||
#: patterns/testimonials-centered.php
|
||||
msgid "Alex Johnson, Creative Director"
|
||||
msgstr "Alex Johnson, Directeur créatif"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
#: functions.php
|
||||
msgid "Pricing"
|
||||
msgstr "Tarifs"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Choose the plan that works best for you."
|
||||
msgstr "Choisissez le forfait qui vous convient le mieux."
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Basic"
|
||||
msgstr "Basique"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Free"
|
||||
msgstr "Gratuit"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "1 Website"
|
||||
msgstr "1 site web"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Community Support"
|
||||
msgstr "Support communautaire"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Core Features"
|
||||
msgstr "Fonctionnalités de base"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Professional"
|
||||
msgstr "Professionnel"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "$49"
|
||||
msgstr "49 $"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "5 Websites"
|
||||
msgstr "5 sites web"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Priority Support"
|
||||
msgstr "Support prioritaire"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "All Features"
|
||||
msgstr "Toutes les fonctionnalités"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Enterprise"
|
||||
msgstr "Entreprise"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "$199"
|
||||
msgstr "199 $"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Unlimited Websites"
|
||||
msgstr "Sites web illimités"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Dedicated Support"
|
||||
msgstr "Support dédié"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
msgid "Custom Development"
|
||||
msgstr "Développement sur mesure"
|
||||
|
||||
#: patterns/pricing-3-col.php
|
||||
#: patterns/page-services.php
|
||||
#: patterns/page-contact.php
|
||||
msgid "Contact Us"
|
||||
msgstr "Contactez-nous"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
msgid "Get in touch"
|
||||
msgstr "Prenez contact"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
msgid "We would love to hear from you. Reach out through any of the channels below."
|
||||
msgstr "Nous serions ravis d'avoir de vos nouvelles. Contactez-nous via l'un des canaux ci-dessous."
|
||||
|
||||
#: patterns/contact-info.php
|
||||
#: patterns/page-contact.php
|
||||
msgid "Address"
|
||||
msgstr "Adresse"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
msgid "123 Example Street"
|
||||
msgstr "123, rue de l'Exemple"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
msgid "8000 Zurich, Switzerland"
|
||||
msgstr "8000 Zurich, Suisse"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
#: patterns/page-contact.php
|
||||
msgid "Phone"
|
||||
msgstr "Téléphone"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
msgid "+41 44 123 45 67"
|
||||
msgstr "+41 44 123 45 67"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
#: patterns/cta-newsletter.php
|
||||
#: patterns/page-contact.php
|
||||
msgid "Email"
|
||||
msgstr "Courriel"
|
||||
|
||||
#: patterns/contact-info.php
|
||||
#: patterns/page-contact.php
|
||||
msgid "info@example.com"
|
||||
msgstr "info@example.com"
|
||||
|
||||
#: patterns/text-faq.php
|
||||
msgid "Frequently Asked Questions"
|
||||
msgstr "Foire aux questions"
|
||||
|
||||
#: patterns/text-faq.php
|
||||
msgid "How do I install the theme?"
|
||||
msgstr "Comment installer le thème ?"
|
||||
|
||||
#: 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 "Téléchargez le fichier ZIP depuis la page des versions, puis importez-le via Administration WordPress > Apparence > Thèmes > Ajouter > Téléverser un thème."
|
||||
|
||||
#: patterns/text-faq.php
|
||||
msgid "Does it work with the Site Editor?"
|
||||
msgstr "Fonctionne-t-il avec l'éditeur de site ?"
|
||||
|
||||
#: 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 "Oui, c'est un thème bloc d'édition complète du site. Vous pouvez personnaliser les modèles, en-têtes, pieds de page et tous les modèles de blocs avec l'éditeur de site WordPress."
|
||||
|
||||
#: patterns/text-faq.php
|
||||
msgid "Can I use my own fonts?"
|
||||
msgstr "Puis-je utiliser mes propres polices ?"
|
||||
|
||||
#: 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 "Le thème est livré avec Inter, Lora et des polices système. Vous pouvez ajouter des polices personnalisées via l'éditeur de site ou en modifiant theme.json."
|
||||
|
||||
#: patterns/text-faq.php
|
||||
msgid "Is dark mode supported?"
|
||||
msgstr "Le mode sombre est-il pris en charge ?"
|
||||
|
||||
#: 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 "Oui, le thème inclut un bouton de mode sombre qui utilise le mode sombre intégré de Bootstrap 5.3. Il respecte les préférences système et mémorise votre choix."
|
||||
|
||||
#: patterns/text-about.php
|
||||
msgid "About us"
|
||||
msgstr "À propos de nous"
|
||||
|
||||
#: 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 "Nous sommes passionnés par la création d'outils qui permettent aux gens de construire de beaux sites web. Notre thème combine la fiabilité de Bootstrap avec la flexibilité de 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 "Avec des années d'expérience en développement web et en design, nous comprenons ce qu'il faut pour créer un thème à la fois puissant et facile à utiliser."
|
||||
|
||||
#: patterns/text-about.php
|
||||
#: patterns/page-about.php
|
||||
msgid "About us image"
|
||||
msgstr "Image à propos de nous"
|
||||
|
||||
#: patterns/hidden-sidebar.php
|
||||
msgid "Recent Posts"
|
||||
msgstr "Articles récents"
|
||||
|
||||
#: patterns/hidden-sidebar.php
|
||||
msgid "Tags"
|
||||
msgstr "Étiquettes"
|
||||
|
||||
#: patterns/dark-mode-toggle.php
|
||||
msgid "Switch to dark mode"
|
||||
msgstr "Passer en mode sombre"
|
||||
|
||||
#: patterns/dark-mode-toggle.php
|
||||
msgid "Switch to light mode"
|
||||
msgstr "Passer en mode clair"
|
||||
|
||||
#: functions.php
|
||||
msgid "Primary Navigation"
|
||||
msgstr "Navigation principale"
|
||||
|
||||
#: functions.php
|
||||
msgid "Footer Navigation"
|
||||
msgstr "Navigation du pied de page"
|
||||
|
||||
#: functions.php
|
||||
msgid "Pages"
|
||||
msgstr "Pages"
|
||||
|
||||
#: functions.php
|
||||
msgid "A collection of full page layouts."
|
||||
msgstr "Une collection de mises en page pleine page."
|
||||
|
||||
#: functions.php
|
||||
msgid "Hero Sections"
|
||||
msgstr "Sections héros"
|
||||
|
||||
#: functions.php
|
||||
msgid "Large hero and banner sections."
|
||||
msgstr "Grandes sections héros et bannières."
|
||||
|
||||
#: functions.php
|
||||
msgid "Call to Action"
|
||||
msgstr "Appel à l'action"
|
||||
|
||||
#: functions.php
|
||||
msgid "Call to action sections."
|
||||
msgstr "Sections d'appel à l'action."
|
||||
|
||||
#: functions.php
|
||||
msgid "Feature and service showcase sections."
|
||||
msgstr "Sections de présentation des fonctionnalités et services."
|
||||
|
||||
#: functions.php
|
||||
msgid "Testimonials"
|
||||
msgstr "Témoignages"
|
||||
|
||||
#: functions.php
|
||||
msgid "Testimonial and review sections."
|
||||
msgstr "Sections de témoignages et d'avis."
|
||||
|
||||
#: functions.php
|
||||
msgid "Pricing table sections."
|
||||
msgstr "Sections de tableaux de tarifs."
|
||||
|
||||
#: functions.php
|
||||
msgid "Contact"
|
||||
msgstr "Contact"
|
||||
|
||||
#: functions.php
|
||||
msgid "Contact information sections."
|
||||
msgstr "Sections d'informations de contact."
|
||||
|
||||
#: functions.php
|
||||
msgid "Text & Content"
|
||||
msgstr "Texte et contenu"
|
||||
|
||||
#: functions.php
|
||||
msgid "Text-focused content sections."
|
||||
msgstr "Sections de contenu axées sur le texte."
|
||||
|
||||
#: functions.php
|
||||
msgid "Checkmark"
|
||||
msgstr "Coche"
|
||||
|
||||
#: functions.php
|
||||
msgid "Unstyled"
|
||||
msgstr "Sans style"
|
||||
|
||||
#: functions.php
|
||||
msgid "Card"
|
||||
msgstr "Carte"
|
||||
|
||||
#: functions.php
|
||||
msgid "Card with Shadow"
|
||||
msgstr "Carte avec ombre"
|
||||
|
||||
#: functions.php
|
||||
msgid "Alert - Info"
|
||||
msgstr "Alerte - Info"
|
||||
|
||||
#: functions.php
|
||||
msgid "Alert - Success"
|
||||
msgstr "Alerte - Succès"
|
||||
|
||||
#: functions.php
|
||||
msgid "Alert - Warning"
|
||||
msgstr "Alerte - Avertissement"
|
||||
|
||||
#: functions.php
|
||||
msgid "Alert - Danger"
|
||||
msgstr "Alerte - Danger"
|
||||
|
||||
#: functions.php
|
||||
msgid "Striped Rows"
|
||||
msgstr "Lignes rayées"
|
||||
|
||||
#: functions.php
|
||||
msgid "Hover Rows"
|
||||
msgstr "Lignes survolées"
|
||||
|
||||
#: functions.php
|
||||
msgid "Bordered"
|
||||
msgstr "Avec bordure"
|
||||
|
||||
#: functions.php
|
||||
msgid "Accent Border"
|
||||
msgstr "Bordure d'accent"
|
||||
|
||||
#: functions.php
|
||||
msgid "Shadow"
|
||||
msgstr "Ombre"
|
||||
|
||||
#: functions.php
|
||||
msgid "Large Rounded"
|
||||
msgstr "Grand arrondi"
|
||||
|
||||
#: functions.php
|
||||
msgid "Large"
|
||||
msgstr "Grand"
|
||||
|
||||
#: functions.php
|
||||
msgid "Small"
|
||||
msgstr "Petit"
|
||||
|
||||
#: functions.php
|
||||
msgid "Wide"
|
||||
msgstr "Large"
|
||||
|
||||
#: functions.php
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: functions.php
|
||||
msgid "Website"
|
||||
msgstr "Site web"
|
||||
|
||||
#: functions.php
|
||||
msgid "Save my name, email, and website in this browser for the next time I comment."
|
||||
msgstr "Enregistrer mon nom, mon courriel et mon site web dans ce navigateur pour la prochaine fois que je commenterai."
|
||||
|
||||
#: functions.php
|
||||
msgid "Bootstrap Layout"
|
||||
msgstr "Mise en page Bootstrap"
|
||||
|
||||
#: functions.php
|
||||
msgid "Bootstrap Components"
|
||||
msgstr "Composants Bootstrap"
|
||||
|
||||
#: functions.php
|
||||
msgid "Bootstrap Navigation"
|
||||
msgstr "Navigation Bootstrap"
|
||||
|
||||
#: functions.php
|
||||
msgid "Layout"
|
||||
msgstr "Mise en page"
|
||||
|
||||
#: functions.php
|
||||
msgid "Layout building blocks for page structure."
|
||||
msgstr "Blocs de construction de mise en page pour la structure des pages."
|
||||
|
||||
#: functions.php
|
||||
msgid "Components"
|
||||
msgstr "Composants"
|
||||
|
||||
#: functions.php
|
||||
msgid "Reusable Bootstrap component patterns."
|
||||
msgstr "Modèles de composants Bootstrap réutilisables."
|
||||
|
||||
#: functions.php
|
||||
#: patterns/footer-columns.php
|
||||
msgid "Navigation"
|
||||
msgstr "Navigation"
|
||||
|
||||
#: functions.php
|
||||
msgid "Navigation and header patterns."
|
||||
msgstr "Modèles de navigation et d'en-tête."
|
||||
|
||||
#: patterns/layout-container.php
|
||||
msgid "Heading goes here"
|
||||
msgstr "Le titre va ici"
|
||||
|
||||
#: patterns/layout-container.php
|
||||
msgid "This is a content container with constrained width and comfortable padding. Replace this text with your own content."
|
||||
msgstr "Ceci est un conteneur de contenu avec une largeur limitée et un espacement confortable. Remplacez ce texte par votre propre contenu."
|
||||
|
||||
#: patterns/layout-2-col.php
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Column One"
|
||||
msgstr "Colonne un"
|
||||
|
||||
#: patterns/layout-2-col.php
|
||||
msgid "Add your content here. This column takes up half the available width on larger screens and stacks on mobile devices."
|
||||
msgstr "Ajoutez votre contenu ici. Cette colonne occupe la moitié de la largeur disponible sur les grands écrans et s'empile sur les appareils mobiles."
|
||||
|
||||
#: patterns/layout-2-col.php
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Column Two"
|
||||
msgstr "Colonne deux"
|
||||
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Column Three"
|
||||
msgstr "Colonne trois"
|
||||
|
||||
#: patterns/layout-3-col.php
|
||||
msgid "Add your content here. This column takes up one third of the available width on larger screens."
|
||||
msgstr "Ajoutez votre contenu ici. Cette colonne occupe un tiers de la largeur disponible sur les grands écrans."
|
||||
|
||||
#: patterns/layout-full-width-section.php
|
||||
msgid "Full Width Section Heading"
|
||||
msgstr "Titre de la section pleine largeur"
|
||||
|
||||
#: patterns/layout-full-width-section.php
|
||||
msgid "This full-width section stands out with a colored background. Use it to highlight important content, announcements, or calls to action."
|
||||
msgstr "Cette section pleine largeur se distingue par un arrière-plan coloré. Utilisez-la pour mettre en valeur du contenu important, des annonces ou des appels à l'action."
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Card One"
|
||||
msgstr "Carte un"
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Card Two"
|
||||
msgstr "Carte deux"
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Card Three"
|
||||
msgstr "Carte trois"
|
||||
|
||||
#: patterns/component-card-group.php
|
||||
msgid "Add a short description for this card. Cards are a great way to organize and present related content."
|
||||
msgstr "Ajoutez une courte description pour cette carte. Les cartes sont un excellent moyen d'organiser et de présenter du contenu connexe."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion"
|
||||
msgstr "Accordéon"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Click on each item to expand and reveal its content."
|
||||
msgstr "Cliquez sur chaque élément pour le développer et révéler son contenu."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion Item One"
|
||||
msgstr "Élément d'accordéon un"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "This is the content for the first accordion item. You can add any blocks inside this details element to create rich, expandable content sections."
|
||||
msgstr "Ceci est le contenu du premier élément d'accordéon. Vous pouvez ajouter n'importe quels blocs à l'intérieur de cet élément details pour créer des sections de contenu riches et dépliables."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion Item Two"
|
||||
msgstr "Élément d'accordéon deux"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "This is the content for the second accordion item. Details blocks are a native HTML element that provide toggle functionality without JavaScript."
|
||||
msgstr "Ceci est le contenu du deuxième élément d'accordéon. Les blocs details sont un élément HTML natif qui offre une fonctionnalité de basculement sans JavaScript."
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "Accordion Item Three"
|
||||
msgstr "Élément d'accordéon trois"
|
||||
|
||||
#: patterns/component-accordion.php
|
||||
msgid "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."
|
||||
msgstr "Ceci est le contenu du troisième élément d'accordéon. Utilisez les accordéons pour organiser les questions fréquentes, les listes de fonctionnalités ou tout contenu qui bénéficie d'une divulgation progressive."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "About Us"
|
||||
msgstr "À propos"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Learn more about who we are, what we do, and the people behind our mission."
|
||||
msgstr "Apprenez-en plus sur qui nous sommes, ce que nous faisons et les personnes derrière notre mission."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Our Story"
|
||||
msgstr "Notre histoire"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "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."
|
||||
msgstr "Fondée avec une passion pour l'innovation et l'excellence, notre aventure a commencé avec une idée simple : créer des solutions significatives qui font une vraie différence. Au fil des années, nous sommes passés d'une petite équipe à un groupe dédié de professionnels engagés à fournir des résultats exceptionnels."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "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."
|
||||
msgstr "Aujourd'hui, nous continuons à repousser les limites et à remettre en question les conventions. Notre approche combine la pensée créative avec des méthodologies éprouvées, garantissant que chaque projet que nous entreprenons répond aux plus hauts standards de qualité et de savoir-faire."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Our Team"
|
||||
msgstr "Notre équipe"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Meet the people who make it all happen."
|
||||
msgstr "Rencontrez les personnes qui rendent tout cela possible."
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Jane Doe"
|
||||
msgstr "Jane Doe"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Founder & CEO"
|
||||
msgstr "Fondatrice et PDG"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "John Smith"
|
||||
msgstr "John Smith"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Lead Developer"
|
||||
msgstr "Développeur principal"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Emily Johnson"
|
||||
msgstr "Emily Johnson"
|
||||
|
||||
#: patterns/page-about.php
|
||||
msgid "Creative Director"
|
||||
msgstr "Directrice créative"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Our Services"
|
||||
msgstr "Nos services"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Professional solutions tailored to your needs."
|
||||
msgstr "Des solutions professionnelles adaptées à vos besoins."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "What We Offer"
|
||||
msgstr "Ce que nous offrons"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "We provide a wide range of services to help your business grow and succeed."
|
||||
msgstr "Nous fournissons une large gamme de services pour aider votre entreprise à croître et réussir."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Design"
|
||||
msgstr "Design"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Beautiful, user-centered designs that capture your brand identity and engage your audience across all platforms and devices."
|
||||
msgstr "De beaux designs centrés sur l'utilisateur qui capturent l'identité de votre marque et engagent votre public sur toutes les plateformes et appareils."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Development"
|
||||
msgstr "Développement"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Robust, scalable web applications built with modern technologies and best practices to ensure performance and reliability."
|
||||
msgstr "Des applications web robustes et évolutives construites avec des technologies modernes et les meilleures pratiques pour garantir performance et fiabilité."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Strategy"
|
||||
msgstr "Stratégie"
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Data-driven strategies and consulting to help you achieve your business goals and stay ahead of the competition."
|
||||
msgstr "Des stratégies basées sur les données et du conseil pour vous aider à atteindre vos objectifs commerciaux et garder une longueur d'avance sur la concurrence."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "Ready to take your project to the next level? Let us help you build something great."
|
||||
msgstr "Prêt à faire passer votre projet au niveau supérieur ? Laissez-nous vous aider à construire quelque chose de formidable."
|
||||
|
||||
#: patterns/page-services.php
|
||||
msgid "View Portfolio"
|
||||
msgstr "Voir le portfolio"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "We would love to hear from you. Reach out to us anytime."
|
||||
msgstr "Nous serions ravis d'avoir de vos nouvelles. Contactez-nous à tout moment."
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Get in Touch"
|
||||
msgstr "Prenez contact"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "123 Main Street, Suite 100, Anytown, ST 12345"
|
||||
msgstr "123, rue Principale, Bureau 100, 75001 Paris"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "+1 (555) 123-4567"
|
||||
msgstr "+33 1 23 45 67 89"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Business Hours"
|
||||
msgstr "Heures d'ouverture"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Monday - Friday:"
|
||||
msgstr "Lundi - Vendredi :"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "9:00 AM - 6:00 PM"
|
||||
msgstr "9h00 - 18h00"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Saturday:"
|
||||
msgstr "Samedi :"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "10:00 AM - 4:00 PM"
|
||||
msgstr "10h00 - 16h00"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Sunday:"
|
||||
msgstr "Dimanche :"
|
||||
|
||||
#: patterns/page-contact.php
|
||||
msgid "Closed"
|
||||
msgstr "Fermé"
|
||||
|
||||
#. translators: Copyright notice. %1$s: Year, %2$s: Site title.
|
||||
#: patterns/footer-minimal.php
|
||||
msgid "© %1$s %2$s. All rights reserved."
|
||||
msgstr "© %1$s %2$s. Tous droits réservés."
|
||||
|
||||
#: patterns/footer-columns.php
|
||||
msgid "A modern WordPress theme built with Bootstrap 5."
|
||||
msgstr "Un thème WordPress moderne construit avec Bootstrap 5."
|
||||
|
||||
#: patterns/footer-columns.php
|
||||
msgid "About"
|
||||
msgstr "À propos"
|
||||
|
||||
#: patterns/footer-columns.php
|
||||
msgid "This theme is proudly built with Bootstrap 5 and WordPress Full Site Editing."
|
||||
msgstr "Ce thème est fièrement construit avec Bootstrap 5 et l'éditeur de site complet WordPress."
|
||||
|
||||
#. translators: Copyright notice. %1$s: Year, %2$s: Site title.
|
||||
#: patterns/footer-columns.php
|
||||
msgid "© %1$s %2$s"
|
||||
msgstr "© %1$s %2$s"
|
||||
|
||||
#: views/base.html.twig
|
||||
msgid "Skip to main content"
|
||||
msgstr "Aller au contenu principal"
|
||||
|
||||
#: views/partials/header.html.twig
|
||||
#: views/partials/header-centered.html.twig
|
||||
#: views/partials/header-transparent.html.twig
|
||||
#: views/partials/header-offcanvas.html.twig
|
||||
msgid "Primary navigation"
|
||||
msgstr "Navigation principale"
|
||||
|
||||
#: views/partials/footer.html.twig
|
||||
#: views/partials/footer-columns.html.twig
|
||||
msgid "Footer navigation"
|
||||
msgstr "Navigation du pied de page"
|
||||
|
||||
#: functions.php
|
||||
msgid "Sidebar"
|
||||
msgstr "Barre latérale"
|
||||
|
||||
#: functions.php
|
||||
msgid "Add widgets here to appear in the sidebar."
|
||||
msgstr "Ajoutez des widgets ici pour les afficher dans la barre latérale."
|
||||
|
||||
#: views/partials/sidebar.html.twig
|
||||
msgid "Blog sidebar"
|
||||
msgstr "Barre latérale du blog"
|
||||
1943
languages/wp-bootstrap-de_CH.po
Normal file
1943
languages/wp-bootstrap-de_CH.po
Normal file
File diff suppressed because it is too large
Load Diff
1940
languages/wp-bootstrap-de_CH_informal.po
Normal file
1940
languages/wp-bootstrap-de_CH_informal.po
Normal file
File diff suppressed because it is too large
Load Diff
1945
languages/wp-bootstrap-de_DE.po
Normal file
1945
languages/wp-bootstrap-de_DE.po
Normal file
File diff suppressed because it is too large
Load Diff
1936
languages/wp-bootstrap-de_DE_informal.po
Normal file
1936
languages/wp-bootstrap-de_DE_informal.po
Normal file
File diff suppressed because it is too large
Load Diff
1882
languages/wp-bootstrap-en_GB.po
Normal file
1882
languages/wp-bootstrap-en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
1898
languages/wp-bootstrap-es_ES.po
Normal file
1898
languages/wp-bootstrap-es_ES.po
Normal file
File diff suppressed because it is too large
Load Diff
1950
languages/wp-bootstrap-fr_CH.po
Normal file
1950
languages/wp-bootstrap-fr_CH.po
Normal file
File diff suppressed because it is too large
Load Diff
1903
languages/wp-bootstrap-fr_FR.po
Normal file
1903
languages/wp-bootstrap-fr_FR.po
Normal file
File diff suppressed because it is too large
Load Diff
1930
languages/wp-bootstrap-it_CH.po
Normal file
1930
languages/wp-bootstrap-it_CH.po
Normal file
File diff suppressed because it is too large
Load Diff
1930
languages/wp-bootstrap-it_IT.po
Normal file
1930
languages/wp-bootstrap-it_IT.po
Normal file
File diff suppressed because it is too large
Load Diff
1894
languages/wp-bootstrap-nl_NL.po
Normal file
1894
languages/wp-bootstrap-nl_NL.po
Normal file
File diff suppressed because it is too large
Load Diff
1912
languages/wp-bootstrap-pl_PL.po
Normal file
1912
languages/wp-bootstrap-pl_PL.po
Normal file
File diff suppressed because it is too large
Load Diff
1900
languages/wp-bootstrap-pt_PT.po
Normal file
1900
languages/wp-bootstrap-pt_PT.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,8 @@
|
||||
"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/",
|
||||
"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"
|
||||
|
||||
21
phpunit.xml.dist
Normal file
21
phpunit.xml.dist
Normal 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>
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 284 KiB |
@@ -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';
|
||||
|
||||
145
src/scss/_widgets.scss
Normal file
145
src/scss/_widgets.scss
Normal 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;
|
||||
}
|
||||
@@ -18,5 +18,8 @@
|
||||
// 5. Bootstrap Icons
|
||||
@import "bootstrap-icons/font/bootstrap-icons";
|
||||
|
||||
// 6. Custom styles
|
||||
// 6. Widget styles
|
||||
@import "widgets";
|
||||
|
||||
// 7. Custom styles
|
||||
@import "custom";
|
||||
|
||||
@@ -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: 1.0.3
|
||||
Version: 1.1.3
|
||||
License: GNU General Public License v2 or later
|
||||
License URI: http://www.gnu.org/licenses/gpl-2.0.html
|
||||
Text Domain: wp-bootstrap
|
||||
|
||||
10
tests/Stubs/WpBlock.php
Normal file
10
tests/Stubs/WpBlock.php
Normal 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
|
||||
{
|
||||
}
|
||||
135
tests/Stubs/WpHtmlTagProcessor.php
Normal file
135
tests/Stubs/WpHtmlTagProcessor.php
Normal 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
10
tests/Stubs/WpWidget.php
Normal 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
|
||||
{
|
||||
}
|
||||
323
tests/Unit/Block/BlockRendererTest.php
Normal file
323
tests/Unit/Block/BlockRendererTest.php
Normal 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') ?? '') : '';
|
||||
}
|
||||
}
|
||||
173
tests/Unit/Block/WidgetRendererTest.php
Normal file
173
tests/Unit/Block/WidgetRendererTest.php
Normal 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>',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
224
tests/Unit/Template/NavWalkerTest.php
Normal file
224
tests/Unit/Template/NavWalkerTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
145
tests/Unit/Template/TemplateControllerTest.php
Normal file
145
tests/Unit/Template/TemplateControllerTest.php
Normal 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
15
tests/bootstrap.php
Normal 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';
|
||||
@@ -11,7 +11,7 @@
|
||||
<a class="wp-bootstrap-skip-link" href="#main-content">{{ __('Skip to main content') }}</a>
|
||||
|
||||
{% block header %}
|
||||
{% include 'partials/header.html.twig' %}
|
||||
{% include 'partials/header-offcanvas.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
<main id="main-content" class="{% block main_class %}py-4{% endblock %}">
|
||||
@@ -19,7 +19,8 @@
|
||||
</main>
|
||||
|
||||
{% block footer %}
|
||||
{% include 'partials/footer.html.twig' %}
|
||||
{# block_template_part('footer') #}
|
||||
{% include 'partials/footer-columns.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{{ wp_footer() }}
|
||||
|
||||
57
views/pages/single-sidebar.html.twig
Normal file
57
views/pages/single-sidebar.html.twig
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<article class="py-4">
|
||||
<header class="mb-4">
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% include 'partials/meta.html.twig' %}
|
||||
</header>
|
||||
|
||||
{% if post.thumbnail %}
|
||||
<figure class="mb-4">
|
||||
<img src="{{ post.thumbnail }}" class="img-fluid rounded post-thumbnail"
|
||||
alt="{{ post.title|e('html_attr') }}"
|
||||
loading="lazy">
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-content">
|
||||
{{ post.content|raw }}
|
||||
</div>
|
||||
|
||||
{% if post.tags|length > 0 %}
|
||||
<div class="mt-4 mb-4">
|
||||
{% for tag in post.tags %}
|
||||
<a href="{{ tag.url }}" class="badge bg-secondary text-decoration-none me-1">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'partials/post-navigation.html.twig' %}
|
||||
{% include 'partials/comments.html.twig' %}
|
||||
</article>
|
||||
|
||||
{% if more_posts is defined and more_posts|length > 0 %}
|
||||
<section class="py-5 border-top">
|
||||
<h2 class="h4 mb-4">{{ __('More posts') }}</h2>
|
||||
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||
{% for post in more_posts %}
|
||||
<div class="col">
|
||||
{% include 'components/card-post-grid.html.twig' with {'post': post} only %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
{% include 'partials/sidebar.html.twig' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<strong class="small">
|
||||
{% if comment.author_url %}
|
||||
<a href="{{ esc_url(comment.author_url) }}" class="text-decoration-none text-body" rel="nofollow">
|
||||
<a href="{{ comment.author_url|esc_url }}" class="text-decoration-none text-body" rel="nofollow">
|
||||
{{ comment.author }}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -18,7 +18,7 @@
|
||||
{{ comment.date }}
|
||||
</time>
|
||||
{% if comment.edit_url %}
|
||||
<a href="{{ comment.edit_url }}" class="text-body-secondary small">{{ __('Edit') }}</a>
|
||||
<a href="{{ comment.edit_url|esc_url }}" class="text-body-secondary small">{{ __('Edit') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="comment-content small">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<ul class="list-unstyled">
|
||||
{% for item in footer_menu %}
|
||||
<li class="mb-1">
|
||||
<a href="{{ item.url }}" class="text-body-secondary text-decoration-none">
|
||||
<a href="{{ item.url|esc_url }}" class="text-body-secondary text-decoration-none">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<ul class="list-unstyled">
|
||||
{% for item in footer_menu %}
|
||||
<li>
|
||||
<a href="{{ item.url }}" class="text-body-secondary text-decoration-none">
|
||||
<a href="{{ item.url|esc_url }}" class="text-body-secondary text-decoration-none">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container flex-column">
|
||||
<a class="navbar-brand fw-bold mb-2" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold mb-2" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
{% if site.description %}
|
||||
@@ -21,7 +21,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -29,7 +29,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -41,7 +41,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="navbarOffcanvas"
|
||||
aria-labelledby="navbarOffcanvasLabel">
|
||||
<div class="offcanvas-header">
|
||||
{% if user.logged_in %}
|
||||
<a href="{{ user.account_url|esc_url }}" class="d-flex align-items-center text-decoration-none">
|
||||
{{ user.avatar|raw }}
|
||||
<span class="ms-2 fw-semibold">{{ user.display_name|esc_html }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<h5 class="offcanvas-title" id="navbarOffcanvasLabel">{{ site.name }}</h5>
|
||||
{% endif %}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"
|
||||
aria-label="{{ __('Close') }}"></button>
|
||||
</div>
|
||||
@@ -25,7 +32,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -33,7 +40,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -45,7 +52,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
@@ -54,12 +61,19 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{% if dark_mode %}
|
||||
<div class="offcanvas-footer d-lg-none border-top p-3">
|
||||
{% include 'partials/dark-mode-toggle.html.twig' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{%- if dark_mode %}
|
||||
<div class="d-none d-lg-block ms-2">
|
||||
{% include 'partials/dark-mode-toggle.html.twig' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header class="position-absolute w-100" style="z-index: 1030;">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -26,7 +26,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -38,7 +38,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -26,7 +26,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -38,7 +38,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form role="search" method="get" action="{{ site.url }}" class="mb-4">
|
||||
<form role="search" method="get" action="{{ site.url|esc_url }}" class="mb-4">
|
||||
<div class="input-group">
|
||||
<input type="search" class="form-control" name="s"
|
||||
placeholder="{{ __('Search...') }}"
|
||||
|
||||
Reference in New Issue
Block a user