5 Commits

Author SHA1 Message Date
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
11 changed files with 164 additions and 17 deletions

4
.markdownlint.json Normal file
View File

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

View File

@@ -2,6 +2,38 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [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 ## [1.0.5] - 2026-02-11
### Added ### Added

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. **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.9**. See `PLAN.md` for details.
## Technical Stack ## Technical Stack
@@ -211,6 +211,60 @@ Build steps (in order):
## Session History ## Session History
### 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) ### Session 13 — v1.0.5 Translation Files (2026-02-11)
**Completed:** Standardized translation file naming and added 11 new locale translations. **Completed:** Standardized translation file naming and added 11 new locale translations.

View File

@@ -20,7 +20,9 @@
*/ */
function getPreferredTheme() { function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY); 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 stored;
} }
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

View File

@@ -175,6 +175,17 @@ add_action( 'wp_enqueue_scripts', 'wp_bootstrap_rtl_styles', 20 );
*/ */
if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) : if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
function 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 // Read the theme origin palette — this contains the base theme.json
// colors merged with the active style variation (if any). // colors merged with the active style variation (if any).
$theme_palette = wp_get_global_settings( array( 'color', 'palette', 'theme' ) ); $theme_palette = wp_get_global_settings( array( 'color', 'palette', 'theme' ) );
@@ -205,10 +216,12 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
// No variation active — let Bootstrap's compiled CSS handle both modes. // No variation active — let Bootstrap's compiled CSS handle both modes.
if ( $is_default ) { if ( $is_default ) {
set_transient( $transient_key, '', DAY_IN_SECONDS );
return; return;
} }
if ( empty( $colors['base'] ) || empty( $colors['contrast'] ) ) { if ( empty( $colors['base'] ) || empty( $colors['contrast'] ) ) {
set_transient( $transient_key, '', DAY_IN_SECONDS );
return; return;
} }
@@ -280,6 +293,9 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
. '[data-bs-theme=light]{' . $light_css . '}' . '[data-bs-theme=light]{' . $light_css . '}'
. '[data-bs-theme=dark]{' . $dark_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 // Attach after the compiled stylesheet so variation values override
// Bootstrap's hardcoded dark-mode defaults via source order. // Bootstrap's hardcoded dark-mode defaults via source order.
wp_add_inline_style( 'wp-bootstrap-style', $css ); wp_add_inline_style( 'wp-bootstrap-style', $css );
@@ -287,6 +303,16 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
endif; endif;
add_action( 'wp_enqueue_scripts', 'wp_bootstrap_variation_colors', 30 ); 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. * Build Bootstrap surface CSS variables for a given background/foreground pair.
* *

View File

@@ -69,6 +69,14 @@ class ContextBuilder
$context['sidebar'] = $this->getSidebarData(); $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; return $context;
} }
@@ -237,8 +245,10 @@ class ContextBuilder
private function getArchiveData(): array private function getArchiveData(): array
{ {
return [ return [
'title' => get_the_archive_title(), // wp_kses_post() allows safe HTML (headings, links, spans) while stripping
'description' => get_the_archive_description(), // 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 +293,10 @@ class ContextBuilder
$tree[] = [ $tree[] = [
'id' => (int) $comment->comment_ID, 'id' => (int) $comment->comment_ID,
'author' => $comment->comment_author, // Escape at source — comment_author is user-supplied, store as safe text.
'author_url' => $comment->comment_author_url, '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]), 'avatar_url' => get_avatar_url($comment, ['size' => 40]),
'date' => get_comment_date('', $comment), 'date' => get_comment_date('', $comment),
'date_iso' => get_comment_date('c', $comment), 'date_iso' => get_comment_date('c', $comment),

View File

@@ -27,7 +27,7 @@ class TwigService
$this->twig = new Environment($loader, [ $this->twig = new Environment($loader, [
'cache' => WP_DEBUG ? false : $cacheDir, 'cache' => WP_DEBUG ? false : $cacheDir,
'debug' => WP_DEBUG, 'debug' => WP_DEBUG,
'auto_reload' => true, 'auto_reload' => WP_DEBUG,
]); ]);
$this->registerWordPressFunctions(); $this->registerWordPressFunctions();
@@ -73,10 +73,12 @@ class TwigService
return _n($single, $plural, $number, $domain); return _n($single, $plural, $number, $domain);
})); }));
// Escaping functions. // Escaping functions — marked is_safe so Twig does not double-escape their output.
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html')); // These functions already return HTML-safe strings; without is_safe, enabling
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr')); // Twig autoescape would double-encode the result (e.g. &amp; → &amp;amp;).
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url')); $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). // WordPress head/footer output (captured via output buffering).
$this->twig->addFunction(new TwigFunction('wp_head', function (): string { $this->twig->addFunction(new TwigFunction('wp_head', function (): string {
@@ -132,10 +134,21 @@ class TwigService
return wp_kses_post($content); return wp_kses_post($content);
}, ['is_safe' => ['html']])); }, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('do_shortcode', function (string $content): string {
return do_shortcode($content);
}, ['is_safe' => ['html']]));
// Formatting. // Formatting.
$this->twig->addFunction(new TwigFunction('number_format_i18n', function (float $number, int $decimals = 0): string { $this->twig->addFunction(new TwigFunction('number_format_i18n', function (float $number, int $decimals = 0): string {
return number_format_i18n($number, $decimals); 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 private function registerWordPressGlobals(): void

View File

@@ -20,7 +20,9 @@
*/ */
function getPreferredTheme() { function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY); 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 stored;
} }
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 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 Requires at least: 6.7
Tested up to: 6.7 Tested up to: 6.7
Requires PHP: 8.3 Requires PHP: 8.3
Version: 1.0.5 Version: 1.0.9
License: GNU General Public License v2 or later License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-bootstrap Text Domain: wp-bootstrap

View File

@@ -19,7 +19,8 @@
</main> </main>
{% block footer %} {% block footer %}
{% include 'partials/footer.html.twig' %} {# block_template_part('footer') #}
{% include 'partials/footer-columns.html.twig' %}
{% endblock %} {% endblock %}
{{ wp_footer() }} {{ wp_footer() }}

View File

@@ -7,7 +7,8 @@
<div class="d-flex align-items-center gap-2 mb-1"> <div class="d-flex align-items-center gap-2 mb-1">
<strong class="small"> <strong class="small">
{% if comment.author_url %} {% 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 }} {{ comment.author }}
</a> </a>
{% else %} {% else %}