8 Commits

Author SHA1 Message Date
77778860ab feat: offcanvas mobile navigation with user avatar and admin bar fix (v1.0.11)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m13s
Create Release Package / Build Release (push) Successful in 1m56s
Switch mobile nav from collapse to offcanvas, add logged-in user avatar
and My Account link to offcanvas header, move dark mode toggle to
offcanvas footer. Fix admin bar overlapping offcanvas via inline CSS.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:44:45 +01:00
26 changed files with 25177 additions and 10987 deletions

4
.markdownlint.json Normal file
View File

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

View File

@@ -2,6 +2,60 @@
All notable changes to this project will be documented in this file.
## [1.0.11] - 2026-02-28
### Changed
- **Offcanvas mobile navigation**: Default header now uses `header-offcanvas.html.twig` instead of `header.html.twig`. Mobile navigation slides in as an offcanvas panel from the right instead of collapsing downward.
- **User avatar in offcanvas header**: When logged in, the offcanvas header displays the user's Gravatar and display name linking to the WooCommerce My Account page (or WP admin profile as fallback). Falls back to the site name when logged out.
- **Dark mode toggle repositioned**: Moved from the offcanvas body to the offcanvas footer on mobile. Desktop toggle remains in the navbar.
### Added
- **User context data** (`inc/Template/ContextBuilder.php`): New `getUserData()` method exposing `user.logged_in`, `user.display_name`, `user.avatar`, and `user.account_url` to all Twig templates.
### Fixed
- **Admin bar overlapping offcanvas** (`functions.php`): Inline CSS via `wp_add_inline_style()` adds `padding-top` matching the admin bar height to `.offcanvas` when the admin bar is visible, preventing content overlap.
## [1.0.10] - 2026-02-25
### Fixed
- **Title double-encoding in Twig templates** (`inc/Template/ContextBuilder.php`): WordPress's `get_the_title()` pre-encodes `&` as `&#038;`. When passed to Twig with autoescape enabled, the `&` in `&#038;` was escaped again to `&amp;#038;`, rendering as literal `&#038;` in the browser (e.g. "Bewerbungen &#038; Nachrichten" instead of "Bewerbungen & Nachrichten"). Fixed by wrapping all 6 `get_the_title()` calls with `wp_specialchars_decode()` to decode WordPress entities before Twig. Twig autoescape then properly re-encodes `&``&amp;`. This is XSS-safe because Twig still escapes all output.
## [1.0.9] - 2026-02-19
### Performance
- **Color variation CSS transient caching** (`functions.php`): `wp_bootstrap_variation_colors()` now caches the generated inline CSS in a 24-hour WordPress transient keyed by `wp_bootstrap_variation_css_` + an MD5 of the active stylesheet slug. Previously the palette iteration and CSS string building ran on every frontend page load. The transient is immediately invalidated on `switch_theme` and `save_post_wp_global_styles`, so changes made via the Design Editor are reflected instantly.
- **Twig template recompilation gated behind `WP_DEBUG`** (`inc/Twig/TwigService.php`): `auto_reload` in the Twig `Environment` constructor was hardcoded to `true`, causing Twig to stat every compiled template file on every request to check for source changes. Changed to `WP_DEBUG` so template recompilation only occurs during development. In production (`WP_DEBUG = false`) compiled Twig templates are served from cache without filesystem mtime checks.
## [1.0.8] - 2026-02-19
### Security
- **Archive XSS hardening**: `ContextBuilder::getArchiveData()` now wraps `get_the_archive_title()` and `get_the_archive_description()` with `wp_kses_post()`. Term descriptions are user-editable by Editors and above; without sanitization an injected `<script>` tag would execute via the `|raw` filter in `archive.html.twig`
- **Comment author XSS hardening**: `ContextBuilder::buildCommentTree()` now applies `esc_html()` to `comment_author` and `esc_url()` to `comment_author_url` at the data source, preventing injection via user-supplied comment fields
- **Dark mode localStorage whitelist**: `getPreferredTheme()` in `dark-mode.js` now validates the stored theme value against `['dark', 'light']` before use, preventing attribute injection from a tampered localStorage value written by a third-party script
- **Twig escaping functions marked safe**: `esc_html()`, `esc_attr()`, and `esc_url()` registered in `TwigService` are now declared with `['is_safe' => ['html']]`, preventing double-encoding if Twig autoescape is ever enabled
### Changed
- `views/partials/comment-item.html.twig`: Comment author URL now output via `{{ comment.author_url|raw }}` (escaped in PHP) instead of calling `esc_url()` from the template, keeping escaping logic in one place
## [1.0.7] - 2026-02-18
### Added
- `do_shortcode()` registered as a Twig function in `TwigService`, allowing shortcodes to be rendered directly from Twig templates via `{{ do_shortcode('[shortcode]') }}`
## [1.0.6] - 2026-02-14
### Fixed
- Sidebar widgets not rendered on pages using the "Page with Sidebar" template — `ContextBuilder::build()` only populated `sidebar` context for `is_home()`, so `page-sidebar.html.twig` received no widget data
## [1.0.5] - 2026-02-11
### Added

121
CLAUDE.md
View File

@@ -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.5**. See `PLAN.md` for details.
Current version is **v1.0.11**. See `PLAN.md` for details.
## Technical Stack
@@ -98,6 +98,29 @@ Compiled .mo files are built by the Gitea CI/CD pipeline during releases. For lo
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
**Important Git Notes:**
@@ -211,6 +234,102 @@ Build steps (in order):
## Session History
### Session 17 — v1.0.11 Offcanvas Navigation & User Context (2026-02-28)
**Completed:** Switched mobile navigation from Bootstrap collapse to offcanvas, added logged-in user context to the header, and fixed admin bar overlap.
**What was changed:**
- **Offcanvas navigation** (`views/base.html.twig`): Default header include switched from `partials/header.html.twig` (collapse) to `partials/header-offcanvas.html.twig` (offcanvas slide-in from right). The offcanvas variant already existed in the theme.
- **Offcanvas header with user avatar** (`views/partials/header-offcanvas.html.twig`): When logged in, the offcanvas header shows the user's Gravatar avatar and display name linking to the WooCommerce My Account page. Falls back to the site name when logged out.
- **Dark mode toggle repositioned**: Moved from the offcanvas body to the offcanvas footer (`d-lg-none`) on mobile. On desktop (≥lg), the toggle remains visible next to the navbar via a separate `d-none d-lg-block` wrapper.
- **User context in ContextBuilder** (`inc/Template/ContextBuilder.php`): New `getUserData()` method providing `user.logged_in`, `user.display_name`, `user.avatar` (rendered `<img>` with `rounded-circle` class), and `user.account_url` (WooCommerce My Account or WP admin profile fallback).
- **Admin bar offcanvas overlap fix** (`functions.php`): Inline CSS injected via `wp_add_inline_style()` when `is_admin_bar_showing()` is true. Adds `padding-top: var(--wp-admin--admin-bar--height, 32px)` to `.offcanvas` so the offcanvas content clears the admin bar.
**Files modified:**
- `views/base.html.twig` — header include changed to offcanvas variant
- `views/partials/header-offcanvas.html.twig` — user avatar header, dark mode toggle in footer
- `inc/Template/ContextBuilder.php``getUserData()` method, `user` key in context
- `functions.php` — admin bar offcanvas padding inline style
- `style.css` — version bump to 1.0.11
- `CHANGELOG.md` — v1.0.11 entry
**Key learnings:**
- Bootstrap offcanvas inside `navbar-expand-lg` uses `position: fixed; top: 0` which is covered by the WordPress admin bar (`z-index: 99999`). Since the offcanvas z-index (1045) is lower, adjusting `top` alone doesn't help visually — `padding-top` on the offcanvas content is the practical fix.
- `wp_add_inline_style()` bypasses file-level browser caching, making it more reliable for conditional CSS rules than editing the main stylesheet.
- WordPress's `--wp-admin--admin-bar--height` CSS custom property (set on `:root`) adjusts between 32px (desktop) and 46px (mobile ≤782px), making it the ideal value for admin bar offset calculations.
- `get_avatar()` accepts an `$args` array where CSS classes can be passed via the `class` key, avoiding post-processing of the HTML output.
### Session 16 — v1.0.10 Title Double-Encoding Fix (2026-02-25)
**Completed:** Fixed double-encoding of HTML entities in page titles rendered through Twig.
**Root cause:** WordPress's `get_the_title()` returns titles with HTML entities pre-encoded (e.g. `&``&#038;`). `ContextBuilder` passed these pre-encoded strings to Twig as template variables. Twig's autoescape then re-encoded the `&` in `&#038;` to `&amp;#038;`, which browsers rendered as the literal text `&#038;` instead of `&`. Affected all pages with `&` in their title (e.g. help pages "Bewerbungen & Nachrichten", "Konto & Sicherheit", "Abonnements & Abrechnung").
**Fix:** Wrapped all 6 `get_the_title()` calls in `ContextBuilder.php` with `wp_specialchars_decode()`. This decodes WordPress entities back to raw characters before Twig, allowing Twig autoescape to properly encode them once. XSS-safe because Twig still escapes all output.
**Files modified:**
- `inc/Template/ContextBuilder.php``wp_specialchars_decode()` on all 6 `get_the_title()` calls
- `style.css` — version bump to 1.0.10
- `CHANGELOG.md` — v1.0.10 entry
### Session 15 — v1.0.9 Performance Optimization (2026-02-19)
**Completed:** Two targeted performance fixes for production environments.
**Changes made:**
- **Color variation CSS transient caching** (`functions.php`): `wp_bootstrap_variation_colors()` now caches the generated Bootstrap CSS variable overrides in a 24-hour transient keyed by `wp_bootstrap_variation_css_` + `md5(get_stylesheet())`. Previously the palette loop and CSS string building executed on every frontend request. Transient is invalidated on `switch_theme` and `save_post_wp_global_styles` hooks so Design Editor changes apply immediately.
- **Twig `auto_reload` gated behind `WP_DEBUG`** (`inc/Twig/TwigService.php`): Hardcoded `auto_reload => true` caused Twig to `stat()` each compiled template file on every request to detect source file changes. Changed to `auto_reload => WP_DEBUG` so stat checks only occur during development. In production, compiled templates are served from cache unconditionally.
**Files modified:**
- `functions.php` — transient caching and invalidation for variation CSS
- `inc/Twig/TwigService.php``auto_reload => WP_DEBUG`
- `style.css` — version bump to 1.0.9
- `CHANGELOG.md` — v1.0.9 entry
### Session 14 — v1.0.8 Security Audit & Hardening (2026-02-19)
**Completed:** Comprehensive OWASP-aligned security audit. Two parallel background agents reviewed all PHP (functions.php, ContextBuilder, NavWalker, TemplateController, TwigService, all patterns) and JavaScript/Twig templates. Four targeted security fixes applied.
**Findings and fixes:**
- **Archive term description XSS (High)**: `get_the_archive_description()` returns raw term content editable by Editor-role users. Templates rendered it with `|raw`, creating a stored XSS path. Fixed: wrapped with `wp_kses_post()` in `ContextBuilder::getArchiveData()`. Same applied to `get_the_archive_title()`.
- **Comment author injection (Low, defense-in-depth)**: `comment_author` and `comment_author_url` were passed to Twig as raw database values. Fixed: `esc_html()` applied to author name, `esc_url()` applied to author URL in `ContextBuilder::buildCommentTree()`. Template updated to output pre-escaped URL via `|raw` rather than calling `esc_url()` in Twig.
- **Dark mode localStorage whitelist (Medium)**: `getPreferredTheme()` returned any stored value without validation, allowing attribute injection if a malicious script wrote to `localStorage`. Fixed: strict equality check against `['dark', 'light']` before trusting the stored value.
- **Twig escaping functions not marked safe (Medium)**: `esc_html()`, `esc_attr()`, `esc_url()` registered in `TwigService` lacked `['is_safe' => ['html']]`, meaning any future autoescape enablement would cause double-encoding. Fixed: all three now carry the `is_safe` declaration.
**Confirmed secure (no action needed):**
- All `|raw` filter usages for widget HTML, comment content, comment reply links, and comment forms are by-design (WordPress core output)
- Pattern files: no direct-access guards needed (loaded only via `register_block_pattern()`)
- No SQL injection vectors (`$wpdb` not used directly; all data via WordPress functions)
- `TemplateController` error handling: `\Throwable` caught, logged, and gated behind `WP_DEBUG`
- `do_shortcode()` and `wp_kses_post()` Twig functions correctly marked `is_safe`
- `wp_head()`, `wp_footer()`, `body_class()` Twig functions correctly use output buffering + `is_safe`
**Key learnings:**
- WordPress Twig themes should not enable `autoescape => 'html'` globally: `get_the_title()` applies `wptexturize()` which returns HTML entities (`&mdash;`, `&ldquo;`). Autoescape would double-encode these, corrupting post title rendering.
- `esc_url()` does more than HTML-encoding — it validates the URL scheme and strips dangerous protocols (`javascript:`, `data:`). Always use it for user-supplied URLs, even when autoescape is active.
- Registering WordPress escaping functions (`esc_url`, `esc_html`, `esc_attr`) as Twig functions without `is_safe => html` silently creates a double-encoding trap: calling `{{ esc_url(url) }}` with autoescape on would produce `&amp;amp;` instead of `&amp;`.
- Added `.markdownlint.json` disabling MD024 (duplicate headings, expected in changelogs) and MD013 (line length).
**Files modified:**
- `inc/Template/ContextBuilder.php` — archive data sanitization, comment field escaping
- `inc/Twig/TwigService.php``is_safe => html` on three escaping functions
- `views/partials/comment-item.html.twig` — use pre-escaped author URL
- `src/js/dark-mode.js` — localStorage whitelist
- `assets/js/dark-mode.js` — rebuilt compiled output
- `style.css` — version bump to 1.0.8
- `CHANGELOG.md` — v1.0.8 entry
- `.markdownlint.json` — created
### Session 13 — v1.0.5 Translation Files (2026-02-11)
**Completed:** Standardized translation file naming and added 11 new locale translations.

View File

@@ -20,7 +20,9 @@
*/
function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
// Whitelist: only honour known-good values to prevent attribute injection
// from a tampered localStorage (e.g. XSS-written value by another script).
if (stored === 'dark' || stored === 'light') {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

View File

@@ -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',
'.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,16 @@ 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() ) );
} );
/**
* Build Bootstrap surface CSS variables for a given background/foreground pair.
*

View File

@@ -32,6 +32,7 @@ class ContextBuilder
'layout' => 'default',
'header_variant' => $this->getHeaderVariant(),
'footer_variant' => $this->getFooterVariant(),
'user' => $this->getUserData(),
];
if (is_singular()) {
@@ -69,6 +70,14 @@ class ContextBuilder
$context['sidebar'] = $this->getSidebarData();
}
// Sidebar data for pages/posts using the "Page with Sidebar" template.
if (is_page() || is_singular('post')) {
$slug = get_page_template_slug();
if ($slug === 'page-sidebar') {
$context['sidebar'] = $this->getSidebarData();
}
}
return $context;
}
@@ -85,6 +94,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 +176,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 +207,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 +268,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()),
];
}
@@ -283,8 +316,10 @@ class ContextBuilder
$tree[] = [
'id' => (int) $comment->comment_ID,
'author' => $comment->comment_author,
'author_url' => $comment->comment_author_url,
// Escape at source — comment_author is user-supplied, store as safe text.
'author' => esc_html($comment->comment_author),
// esc_url() strips dangerous schemes (javascript:, data:) and encodes for HTML.
'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),
@@ -337,14 +372,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 +407,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'),
@@ -426,7 +461,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(),
];

View File

@@ -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; → &amp;amp;).
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html', ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr', ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url', ['is_safe' => ['html']]));
// WordPress head/footer output (captured via output buffering).
$this->twig->addFunction(new TwigFunction('wp_head', function (): string {
@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,9 @@
*/
function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
// Whitelist: only honour known-good values to prevent attribute injection
// from a tampered localStorage (e.g. XSS-written value by another script).
if (stored === 'dark' || stored === 'light') {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

View File

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

View File

@@ -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() }}

View File

@@ -7,7 +7,8 @@
<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">
{# author_url is pre-escaped with esc_url() in ContextBuilder #}
<a href="{{ comment.author_url|raw }}" class="text-decoration-none text-body" rel="nofollow">
{{ comment.author }}
</a>
{% else %}

View File

@@ -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 }}" 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>
@@ -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>