Latest Releases
These releases come straight from our repositories. Latest releases may not be published yet.
swissup / core
1.14.0 Sepswissup / firecheckout
1.36.0 Sep-
1.36.0
Report (rev.1)
Package was generated and uploaded to GitHub on with the following dependencies:
swissup/module-address-autocomplete:1.8.4
swissup/module-address-field-manager:1.6.21
swissup/module-address-validation:1.0.4
swissup/module-bot-protection:1.0.2
swissup/module-checkout-cart:1.5.10
swissup/module-checkout-consumer-rights:1.1.0
swissup/module-checkout-fields:1.6.17
swissup/module-checkout-registration:1.2.3
swissup/module-checkout-success:2.0.12
swissup/module-customer-field-manager:1.2.11
swissup/module-delivery-date:1.9.0
swissup/module-firecheckout:1.36.0
swissup/module-geoip:1.6.2
swissup/module-orderattachment:1.5.3
swissup/module-subscribe-at-checkout:1.3.6
swissup/module-taxvat:1.4.9
swissup/module-checkout:1.5.4
swissup/module-core:1.13.1
swissup/module-field-manager:1.3.7
swissup/module-firecheckout-integrations:1.4.10
Changelog (autogenerated)
swissup/module-address-autocomplete — 1.8.4 (was 1.8.3)
-
Version 1.8.4 d5c54d
-
Fixed proper city detection for some addresses
```
Not Working: Via Spinazzola, 24, Rovolon, PD, Italia
Working: Via Spinazzola, 24, Roma
```
https://developers.google.com/maps/documentation/places/web-service/legacy/supported_types:
```
The (cities) type collection instructs the Places service to return results that match locality or administrative_area_level_3.
``` 7578e9
swissup/module-checkout-consumer-rights — 1.1.0 (was 1.0.0)
-
Version 1.1.0 02da05
-
Allow to withdraw order from customer account page 58b399
-
Better naming for emails templates 009a6e
-
Feature: send notification email to customer 816777
-
Add order Grid items variable 3f1522
-
Order items block expects DB order_id b64f33
-
Fixed not working custom template 6f1f41
-
Version 1.0.2 0c6a57
-
Add translation to 'Read your legal guarantee rights' phrase af71dd
-
Version 1.0.1 61551f
-
Added missing Breeze integration e2467d
swissup/module-checkout-fields — 1.6.17 (was 1.6.16)
-
Version 1.6.17 61f0d4
-
Fixed double-escape in email template c41827
-
Mage-OS 3.5.0 compatibility: moved block used in email from Adminhtml f636e5
swissup/module-checkout-success — 2.0.12 (was 2.0.11)
swissup/module-delivery-date — 1.9.0 (was 1.8.1)
swissup/module-firecheckout — 1.36.0 (was 1.35.0)
-
Version 1.36.0 c6b511
-
Fix readonly env: deploy jsbuild during static content deployment f29be0
-
Fix "Maximum call stack size exceeded" in jsbuild
mage/requirejs/static.js patches the global require.load and parks the
previous function on require._load. That patch is not idempotent, and its
modules are reachable two ways on a FireCheckout page: as the plain
<script> we inject after require.js, and via the bundles entry below.
requirejs keeps `defined` per context, so with a second context on the
page (e.g. "$" next to "_") the jsbuild factory runs twice: require._load
then holds the first wrapper, whose own fallback is itself, and
require.load recurses forever. Symptoms are the RangeError plus
"Load timeout for modules: mage/requirejs/text".
Pin require._load to the real loader before static.js can install the
wrapper, so any number of jsbuild evaluations stay harmless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bed9ef -
Replace curl-powered deployment with Asset\Publisher
This commit should improve speed of jsbuild in development mode a13472 -
Replace curl-powered deployment with Asset\Publisher
This commit should improve speed of jsbuild in development mode 46c4d4
swissup/module-core — 1.13.1 (was 1.12.27)
-
Version 1.13.1 5b7b24
-
Fixed invisible update instructions when Mollie Payments is installed 72aab6
-
Version 1.13.0 f34c7b
-
Fill the links after ajax refresh b6a316
-
Show loading state on the "Check for Updates" button 1ab16e
-
Use optimized repo to fetch latest releases 1aa8fb
-
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a -
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2 -
Mark core section with badge when outdated modules found 9a3764
-
Outdated modules badge and Modules config group (#25) 7df026
-
MCS fix 3b9121
-
Update instructions 0e686f
-
Read links info from marketplace meta 21e95b
-
Keep the last check time when a refresh fails
Removing the stored time was what made the next load re-check the feed.
An unreachable feed left no time at all: the config page had nothing to
display, and every page load kept re-checking until one request made it
through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9 -
Post the modules refresh to an action of its own
The refresh drops the version check throttle and re-reads the remote
source, and a get carries no form key validation - Magento checks it for
post requests only, and the url secret key is optional. Reading the list
stays a get, so the two live in separate actions now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9 -
Keep the module links out of the response
The links cell is rendered by the server and never touched by js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db -
Send only the module metadata the browser renders
The merged Loader record carries the local filesystem path and the
download, license and purchase-code fields of the remote feed. None of
them is rendered by the config page, so keep them on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425 -
Too much red 2e307e
-
Use the same `outdated` class name everywhere
The version span was already renamed, while the row and the js still said
`_outdated` - a row that stopped being outdated kept its red version pill.
Since the row now wears the very same class, the pill rule had to be
scoped to the version cell, or the whole row would turn red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086 -
Report the check time with the modules, and keep outdated rows first
Two things the clients were guessing at:
The `Check for Updates` handler stamped the label with the current time,
but the request may have re-used the stored packages after a failed feed
request - `just now` was not always true. And an automatic load did check
the remote source, yet nothing updated the label at all. The endpoint now
returns `['items' => ..., 'last_check' => ...]`, and the label follows the
time the server reports for every load.
The table was re-rendered cell by cell, so a module that became outdated
kept its old position while the counter above it grew. Mark the rows and
move the outdated ones back to the top - both groups stay sorted by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271 -
Keep the last check time once the check is due
The timestamp was stored with a one hour lifetime and the throttle was
`is the entry still there?`. Handy, but it also meant the config page
could no longer tell how old the data was as soon as the check was due -
exactly when that is worth showing next to `Check for Updates`.
Store it without a lifetime and compare the age instead. Same hour
boundary, and the label now reads `Last checked 2 h ago` instead of
disappearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a -
Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66
-
Hovered color for outdated badge 0a0016
-
Show outdated modules count in config and add Modules group
The admin had no hint that installed Swissup modules have updates
available - one had to open Module Manager to find out.
Add a counter badge to the Swissup tab of the config navigation, and a
Modules group in Swissup > Core listing every installed module with its
installed and latest version, outdated ones first.
Neither of them may slow down the config page, so both render from the
data stored in var/swissup/core (Loader::setOfflineMode()), and the
versions, the counters and the badge itself are then refreshed by a
single ajax request shared by all the consumers. `Check for Updates`
re-runs that request with refresh=1, which drops the version check
throttle and re-reads the remote source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6 -
Move core config to the top as it will display outdated modules 115f37
-
Added ability to immidiately re-check remote source for updates `-r` 357d33
-
Keep the remote packages data in files instead of the cache (#24) cbeecc
-
Trim the comments down to what is not in the code
The two lock docblocks carried the same paragraph, and save() explained
the reasoning that belongs in the commit that introduced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9 -
Serialize the writes with a lock taken before the entry is opened
openFile() truncates whatever it opens, so a lock taken afterwards keeps
the writes apart but not the truncations. Two saves could interleave into
one file: the second one truncates, the first one writes a whole entry,
and the second one then overwrites its beginning - leaving the head of
one entry on the tail of another. The length line does not catch that
when both entries encode to the same size, as fixed width values like a
version hash always do.
Take the lock before opening, on a file of its own - the entry cannot be
opened before the lock is held, and the file lock() uses is likely held
by the caller already, which flock would deadlock against.
Failing to open a lock file is no longer reported as a lock held by
somebody else. A lock left behind by another user cannot be opened at
all, and reporting that as contention kept the caller away from the
entry for good - silently serving a list that could never be refreshed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28 -
Don't download the list that another process has just stored
The version is fetched before the download lock is taken, so a process
that was still asking for it when the lock was released would go on to
download the very list the lock holder had just stored. Look at the
storage once more before downloading.
Also correct what the save() lock claims to do: it serializes the
writes, but the truncation happens in openFile(), outside of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0 -
Reject an entry whose expiration time is not a number
A corrupt header such as `expires:invalid` was cast to zero, which reads
as "never expires" - so an entry that should have gone stale could be
served indefinitely instead of being treated as missing. Validate the
field the same way the length line is validated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16 -
Keep the remote packages data in files instead of the cache
The packages list was kept in the Magento cache, so every cache flush
threw it away and the next admin request had to download it again. It is
now stored under var/swissup/core, where it outlives the flush.
FileStorage writes the expiration time and the data length ahead of the
contents, so an entry knows itself when it goes stale, and a reader can
tell a half-written entry from a complete one - openFile() truncates the
file before the write lock is taken, so a reader can catch the entry
mid-save. An incomplete entry is reported as missing and refetched,
rather than served as if it were whole.
Remote revalidates against the version hash from packages.json, at most
once an hour, and only downloads the full list when that hash moves. The
download itself is guarded by a non-blocking lock, so several admins
hitting the page at once produce one request instead of one each - the
others serve the stored copy, or wait for the download when there is
nothing stored yet. fetch() now reports connection errors and 4xx/5xx
responses by returning an empty body, letting the caller fall back to
the stored data instead of decoding a failure page.
The admin notification feed keeps its last-update timestamp in the same
storage, replacing its own copy of the file handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd -
Small fixes f7eef6
-
Single source of truth for is_outdated, is_installed 37ad01
-
Fixed `Undefined array key "type"` warning b2a9c1
-
Don't show non installed modules when asking for outdated bf5fb1
swissup/module-firecheckout-integrations — 1.4.10 (was 1.4.8)
-
swissup / delivery-date
1.9.0 Sep-
1.9.0
Report (rev.1)
Package was generated and uploaded to GitHub on with the following dependencies:
swissup/module-delivery-date:1.9.0
swissup/module-checkout:1.5.4
swissup/module-core:1.13.1
Changelog (autogenerated)
swissup/module-delivery-date — 1.9.0 (was 1.7.11)
-
Version 1.9.0 1ed62b
-
Delivery date on PaypalExpress checkout review page #17 d2f2eb
-
Version 1.8.1 7f2f72
-
Fixed missing holiday when offset is empty string 90c827
-
Remove private repo from readme, because users add it too 5caa73
-
Version 1.8.0 47e33b
-
Improve naming 413c70
-
Added option that allows to exclude weekends and holidays from offset
Closes #18 850b50 -
Remove old mixin (Magento < 2.2.2) 21dca6
-
Version 1.7.12 8a52a1
-
Fixed unescaped output, closes #16 d6c289
swissup/module-checkout — 1.5.4 (was 1.5.2)
-
Version 1.5.4 e06d43
-
checkout-consumer-rights icon 027810
-
Version 1.5.3 d22b9f
-
Breeze: update integration 880a83
-
Breeze: exclude requirejs-config files 019ad4
-
Revert "Icon for security module"
This reverts commit 088ee1bf78be5f00695d2a469937f090d865688f. c1f27c -
Icon for security module 088ee1
swissup/module-core — 1.13.1 (was 1.12.22)
-
Version 1.13.1 5b7b24
-
Fixed invisible update instructions when Mollie Payments is installed 72aab6
-
Version 1.13.0 f34c7b
-
Fill the links after ajax refresh b6a316
-
Show loading state on the "Check for Updates" button 1ab16e
-
Use optimized repo to fetch latest releases 1aa8fb
-
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a -
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2 -
Mark core section with badge when outdated modules found 9a3764
-
Outdated modules badge and Modules config group (#25) 7df026
-
MCS fix 3b9121
-
Update instructions 0e686f
-
Read links info from marketplace meta 21e95b
-
Keep the last check time when a refresh fails
Removing the stored time was what made the next load re-check the feed.
An unreachable feed left no time at all: the config page had nothing to
display, and every page load kept re-checking until one request made it
through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9 -
Post the modules refresh to an action of its own
The refresh drops the version check throttle and re-reads the remote
source, and a get carries no form key validation - Magento checks it for
post requests only, and the url secret key is optional. Reading the list
stays a get, so the two live in separate actions now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9 -
Keep the module links out of the response
The links cell is rendered by the server and never touched by js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db -
Send only the module metadata the browser renders
The merged Loader record carries the local filesystem path and the
download, license and purchase-code fields of the remote feed. None of
them is rendered by the config page, so keep them on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425 -
Too much red 2e307e
-
Use the same `outdated` class name everywhere
The version span was already renamed, while the row and the js still said
`_outdated` - a row that stopped being outdated kept its red version pill.
Since the row now wears the very same class, the pill rule had to be
scoped to the version cell, or the whole row would turn red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086 -
Report the check time with the modules, and keep outdated rows first
Two things the clients were guessing at:
The `Check for Updates` handler stamped the label with the current time,
but the request may have re-used the stored packages after a failed feed
request - `just now` was not always true. And an automatic load did check
the remote source, yet nothing updated the label at all. The endpoint now
returns `['items' => ..., 'last_check' => ...]`, and the label follows the
time the server reports for every load.
The table was re-rendered cell by cell, so a module that became outdated
kept its old position while the counter above it grew. Mark the rows and
move the outdated ones back to the top - both groups stay sorted by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271 -
Keep the last check time once the check is due
The timestamp was stored with a one hour lifetime and the throttle was
`is the entry still there?`. Handy, but it also meant the config page
could no longer tell how old the data was as soon as the check was due -
exactly when that is worth showing next to `Check for Updates`.
Store it without a lifetime and compare the age instead. Same hour
boundary, and the label now reads `Last checked 2 h ago` instead of
disappearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a -
Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66
-
Hovered color for outdated badge 0a0016
-
Show outdated modules count in config and add Modules group
The admin had no hint that installed Swissup modules have updates
available - one had to open Module Manager to find out.
Add a counter badge to the Swissup tab of the config navigation, and a
Modules group in Swissup > Core listing every installed module with its
installed and latest version, outdated ones first.
Neither of them may slow down the config page, so both render from the
data stored in var/swissup/core (Loader::setOfflineMode()), and the
versions, the counters and the badge itself are then refreshed by a
single ajax request shared by all the consumers. `Check for Updates`
re-runs that request with refresh=1, which drops the version check
throttle and re-reads the remote source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6 -
Move core config to the top as it will display outdated modules 115f37
-
Added ability to immidiately re-check remote source for updates `-r` 357d33
-
Keep the remote packages data in files instead of the cache (#24) cbeecc
-
Trim the comments down to what is not in the code
The two lock docblocks carried the same paragraph, and save() explained
the reasoning that belongs in the commit that introduced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9 -
Serialize the writes with a lock taken before the entry is opened
openFile() truncates whatever it opens, so a lock taken afterwards keeps
the writes apart but not the truncations. Two saves could interleave into
one file: the second one truncates, the first one writes a whole entry,
and the second one then overwrites its beginning - leaving the head of
one entry on the tail of another. The length line does not catch that
when both entries encode to the same size, as fixed width values like a
version hash always do.
Take the lock before opening, on a file of its own - the entry cannot be
opened before the lock is held, and the file lock() uses is likely held
by the caller already, which flock would deadlock against.
Failing to open a lock file is no longer reported as a lock held by
somebody else. A lock left behind by another user cannot be opened at
all, and reporting that as contention kept the caller away from the
entry for good - silently serving a list that could never be refreshed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28 -
Don't download the list that another process has just stored
The version is fetched before the download lock is taken, so a process
that was still asking for it when the lock was released would go on to
download the very list the lock holder had just stored. Look at the
storage once more before downloading.
Also correct what the save() lock claims to do: it serializes the
writes, but the truncation happens in openFile(), outside of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0 -
Reject an entry whose expiration time is not a number
A corrupt header such as `expires:invalid` was cast to zero, which reads
as "never expires" - so an entry that should have gone stale could be
served indefinitely instead of being treated as missing. Validate the
field the same way the length line is validated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16 -
Keep the remote packages data in files instead of the cache
The packages list was kept in the Magento cache, so every cache flush
threw it away and the next admin request had to download it again. It is
now stored under var/swissup/core, where it outlives the flush.
FileStorage writes the expiration time and the data length ahead of the
contents, so an entry knows itself when it goes stale, and a reader can
tell a half-written entry from a complete one - openFile() truncates the
file before the write lock is taken, so a reader can catch the entry
mid-save. An incomplete entry is reported as missing and refetched,
rather than served as if it were whole.
Remote revalidates against the version hash from packages.json, at most
once an hour, and only downloads the full list when that hash moves. The
download itself is guarded by a non-blocking lock, so several admins
hitting the page at once produce one request instead of one each - the
others serve the stored copy, or wait for the download when there is
nothing stored yet. fetch() now reports connection errors and 4xx/5xx
responses by returning an empty body, letting the caller fall back to
the stored data instead of decoding a failure page.
The admin notification feed keeps its last-update timestamp in the same
storage, replacing its own copy of the file handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd -
Small fixes f7eef6
-
Single source of truth for is_outdated, is_installed 37ad01
-
Fixed `Undefined array key "type"` warning b2a9c1
-
Don't show non installed modules when asking for outdated bf5fb1
-
Version 1.12.27 ac70c7
-
Magento 2.4.9 fix (CLI execute command) d224be
-
Prevent news retrieval after each cache flush ac96b7
-
Version 1.12.26 74dff6
-
Improve the libxml fix to include `data-post` and `data-config` attrs 3fee5e
-
Version 1.12.25 2186f6
-
Fixed broken markup when using newer libxml version (2.15.1) (#23) 61ceab
-
Use same quotes c33f29
-
Update Plugin/FixHtmlMarkup.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> e056e2 -
Fixed broken markup when using newer libxml version (2.15.1) 7ff0db
-
Version 1.12.24 4ac8cb
-
Fixed unescaped output, closes #22 67c9f1
-
Version 1.12.23 13d1c7
-
Fixed integrity constraint violation: Duplicate entry for key primary b9d95d
-
swissup / checkout-fields
1.6.17 Sepbreezefront / breeze-content-builder
1.2.2 Sepbreezefront / breeze-blank
3.2.2 Sepswissup / argento-breeze
3.2.0 Aug-
3.2.0
Report (rev.1)
Package was generated and uploaded to GitHub on with the following dependencies:
swissup/module-ajaxpro:1.7.39
swissup/module-ajaxsearch:1.14.11
swissup/module-amp:1.6.0
swissup/module-askit:1.14.27
swissup/module-attributepages:1.8.6
swissup/module-breeze-ai:1.4.0
swissup/module-breeze-content-builder-migration:1.0.1
swissup/module-breeze-layout-builder:1.2.0
swissup/module-chat-gpt-assistant:1.1.4
swissup/module-compare:1.2.8
swissup/module-easy-slide:1.8.18
swissup/module-easybanner:1.9.24
swissup/module-easycatalogimg:1.5.15
swissup/module-easytabs:1.13.4
swissup/module-fblike:1.3.9
swissup/module-featured-attributes:1.1.9
swissup/module-gdpr:1.9.2
swissup/module-gdpr-askit:1.1.3
swissup/module-gdpr-reviewreminder:1.0.0
swissup/module-gdpr-testimonials:1.1.1
swissup/module-highlight:1.11.9
swissup/module-hover-gallery:1.3.15
swissup/module-hreflang:1.6.12
swissup/module-hreflang-import-export:1.0.1
swissup/module-navigationpro:1.19.3
swissup/module-pagespeed:1.19.0
swissup/module-pro-labels:1.10.1
swissup/module-pro-labels-configurable-product:1.0.9
swissup/module-quantity-switcher:1.2.4
swissup/module-reviewreminder:1.1.19
swissup/module-rich-snippets:1.8.5
swissup/module-seo-canonical:1.11.10
swissup/module-seo-core:1.3.10
swissup/module-seo-cross-links:1.2.0
swissup/module-seo-html-sitemap:1.1.23
swissup/module-seo-images:1.3.21
swissup/module-seo-pager:1.4.0
swissup/module-seo-templates:1.7.12
swissup/module-seo-urls:1.5.55
swissup/module-seo-xml-sitemap:1.2.0
swissup/module-sold-together:1.11.4
swissup/module-sold-together-email:1.0.0
swissup/module-sold-together-import-export:1.0.2
swissup/module-testimonials:1.4.2
swissup/module-theme-editor-argentobreeze-business:1.0.2
swissup/module-theme-editor-argentobreeze-chic:1.0.3
swissup/module-theme-editor-argentobreeze-force:1.1.1
swissup/module-theme-editor-argentobreeze-stripes:1.1.2
swissup/theme-frontend-argentobreeze-blank:3.1.0
swissup/theme-frontend-argentobreeze-business:3.0.1
swissup/theme-frontend-argentobreeze-chic:3.0.0
swissup/theme-frontend-argentobreeze-force:3.0.1
swissup/theme-frontend-argentobreeze-stripes:3.1.3
swissup/module-core:1.13.1
swissup/module-suggestpage:1.2.14
swissup/module-breeze-content-builder:1.2.0
swissup/module-image-optimizer:1.0.12
swissup/module-theme-editor:1.12.0
Changelog (autogenerated)
swissup/module-ajaxpro — 1.7.39 (was 1.7.36)
-
Version 1.7.39 650093
-
fix: guard productInfoResolver call in ajaxSubmit (#57 follow-up)
Found while verifying the YQE-14065 fix on the customer's dev store:
their theme (Webspaceconfig/Webspace) ships its own outdated copy of
Magento_Catalog/js/catalog-add-to-cart.js that predates Magento core
adding productInfoResolver support. Our widget extends that file as
its base ($.mage.catalogAddToCart) and called
self.options.productInfoResolver(form) unconditionally, so on that
theme every ajax add-to-cart submit threw
"productInfoResolver is not a function" before the request was even
sent — configurable/size options couldn't be added to cart from
category listing pages.
Root cause is the theme's stale file, not this module — reported
separately. This guard just keeps ajaxpro working regardless of
whether the base widget a theme provides happens to support this
option.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 320f71 -
Version 1.7.38 3b2284
-
Merge pull request #58 from swissup/fix/gift-card-options-data-post-57
fix: stop stripping data-post so third-party widgets can still read it (#57) eb4490 -
fix: pass bind:false when reusing ajaxcian-data-post as an _ajax() helper (#57)
Copilot review on #58: _confirm()'s replacement click handler runs on
every confirmed compare remove/clear click and constructs
AjaxproAjaxcianDataPost({}, element) to reuse its _ajax() method. With
the default options ({} means options.bind stays true), _create()
also calls _bind(), adding a brand new click listener on the same
element — and since data-post is no longer stripped (YQE-14065), that
_bind() call succeeds every time instead of bailing out on a missing
attribute like it used to.
None of those listeners are ever removed, so click N fires N ajax
requests: click 1 adds listener #1 (plus its own direct call), click 2
runs the _confirm handler directly again AND listener #1, click 3 runs
the _confirm handler, listener #1, and listener #2, etc.
Fixed by passing { bind: false } — this call only ever wanted _ajax()
as a helper method. Verified by reverting locally and re-running the
new test: 2nd click produces 3 ajax calls instead of 2 without the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> c5bd0f -
fix: stop propagation in compare.js rebind, close double-submit (#57)
Copilot review on #58: Swissup_Ajaxpro/js/compare's _confirm() rebinds
the click handler on compare remove/clear-all links on every
compare-products section update:
$(selector).off('click').on('click', function (e) { ... });
.off('click') strips ANY click handler already on the element,
including ajaxcian-data-post's own (which stopPropagation()s). The
replacement handler here called _ajax() directly without stopping
propagation of its own. Since ajaxcian-data-post no longer strips
data-post, a click after this rebind reached both this handler's ajax
call and the native document-delegated mage.dataPost handler — a real
double submit for compare remove/clear links specifically. Previously
this was masked because data-post was already gone by the time any
rebind happened.
Added e.stopPropagation() to this handler too, plus a regression test
that reproduces the exact rebind sequence and asserts exactly one ajax
call / zero native-handler calls (and fails without the fix, verified
locally). Needed two additions to the jest $.widget shim along the
way: this._super() support (compare.js's _create calls it) and calling
the widget constructor without `new` (compare.js does
AjaxproAjaxcianDataPost({}, element) directly, as real jQuery UI
widget constructors support).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 727bd4 -
fix: bind click handler synchronously, drop startup delay (#57)
Copilot review on #58: the widget still delayed binding its own click
handler by 500ms. That delay used to be harmless because the old
buggy code removed data-post synchronously in _bind(), so the native
mage.dataPost document-delegated handler couldn't match during the
window either. Now that data-post stays on the element permanently,
a click within that window reached mage.dataPost alone and triggered
a full page navigation instead of Ajaxpro's ajax request.
No commit in this file's history documents a reason for the delay.
Binding synchronously closes the window; our handler is on the
element itself so it still runs (and stopPropagation()s) before the
event can reach the document-level native handler regardless of when
it was bound, as long as it's bound before the click happens.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> cc70c1 -
fix: clone cached data-post before mutating it (#57)
Copilot review on #58: element.data('post') returns jQuery's cached
object for the attribute (same reference on every call). _ajax() was
mutating that object's .data (adding form_key) in place, so a later
$(el).data('post') read — by this widget on a second click, or by a
third party like Mageplaza_GiftCard/js/view/information.js — would see
the leaked form_key instead of the original payload.
Also documents the stopPropagation() trade-off raised in the same
review: it can't be scoped to only the native mage.dataPost handler
(jQuery event namespaces don't affect propagation, and mage/dataPost.js
is a core file we don't patch) — no other ancestor-delegated click
listener touches these links today, so accepted as-is.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 75a2b4 -
fix: stop stripping data-post so third-party widgets can still read it (#57)
ajaxcian-data-post.js renamed data-post to data-post-ajax and removed the
original attribute when hijacking a link for ajax submit. Any other script
reading $(el).data('post') from the same link after that point (e.g.
Mageplaza_GiftCard/js/view/information.js, on the same .tocompare /
.towishlist links used to build Gift Card add-to-wishlist/compare payloads)
got undefined and threw, aborting its own init before it could render the
Gift Card options form.
Keep data-post intact and use stopPropagation() instead to stop the native
mage.dataPost document-delegated click handler from double-submitting.
Verified live on the customer's dev store: Gift Card options render
correctly with the patch, no duplicate ajax request on click.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 508331 -
Breeze: added missing imports 115dd9
-
Version 1.7.37 264578
-
fix: quick view popup silenced by the 1.7.35 gating guard
is-ajaxpro-request's "trust a section load only after a recent ajaxpro
POST" guard (048361e, 1.7.35) only recognized add-to-cart as a plain
GET customer/section/load preceded by a POST. Quick view has no such
POST — it fetches ajaxpro-product directly — so ModalManager never
opened the popup: the request succeeded, the section arrived, nothing
rendered, no error.
Have quick-view declare the request explicitly via the new
isAjaxproRequest.mark() instead of relying on inference.
Reported on 420shop.nl (customer-facing, all listing pages).
Closes #57 30e663 -
fix: ajaxpro-cart/reinit/product section render must not 400 section/load
Magento\Customer\Controller\Section\Load turns any exception thrown by
a section source into an HTTP 400 for the whole request. Cart::getSectionData()
renders the full cart layout server-side even for an empty/absent quote,
and on some environments (Magento 2.4.9 + Breeze, per report) that render
throws, aborting customer/section/load for every guest with an empty cart.
Wrap the render logic in Cart, Init and Product CustomerData classes in
try/catch: on failure, log via the newly injected Psr\Log\LoggerInterface
and return [] for that section instead of letting the exception bubble up.
Core sections (cart, customer, ...) keep loading either way.
Closes #56 5f061c -
docs: add manual QA scenarios ba69b1
swissup/module-ajaxsearch — 1.14.11 (was 1.14.10)
-
Version 1.14.11 0db900
-
Fixed layout shift when result list is long 8715cc
-
Fixed overflowed content in Firefox (Apollo theme) de660b
-
Merge pull request #57 from swissup/phpunit12-test-api-56
Migrate Test/Unit off removed PHPUnit APIs (PHPUnit 12 / PHP 8.5) 095093 -
test: migrate removed test APIs for PHPUnit 12 / PHP 8.5
The CI runner targets PHP 8.5, which requires PHPUnit 12.x. Migrate the
unit tests off APIs removed in PHPUnit 10-12:
- setMethods() (removed v10) -> onlyMethods(); all mocked methods verified
to exist on the target class via reflection. Dropped dead duplicate mock
builders (Magento CollectionFactory immediately overwritten by the
Swissup one).
- getMockForAbstractClass() (removed v12) -> createMock().
- will($this->returnValue()/returnSelf()/onConsecutiveCalls()) (removed
v12) -> willReturn()/willReturnSelf()/willReturnOnConsecutiveCalls().
- Non-static data providers (hard error v12) -> public static function.
Also fixed two removed APIs not listed in the issue but caught while
running the suite:
- $this->at() (removed v10) in DataTest and ProductTest -> willReturnMap()
/ atLeastOnce() / consecutive-call stubs.
InitTest was stale independently of PHPUnit: it exercised a removed
getClassNames() method and the old constructor/getSettings signature.
Realigned it to the current Init block and dropped the redundant
getClassNames test (already covered by Helper\DataTest).
Verified: 41 tests, 99 assertions, 0 errors, 0 deprecations on
PHPUnit 10.5 / PHP 8.3 (`bin/test`).
Refs swissup/module-ajaxsearch#56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 20854c -
test: add module-local PHPUnit runner
Add bin/test and phpunit.xml.dist so the unit suite can be run in
isolation via `bin/test` (runs vendor/bin/phpunit inside the container),
mirroring the setup already present in module-askit. .gitignore keeps the
PHPUnit result cache out of the repo.
Refs swissup/module-ajaxsearch#56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> a4e899
swissup/module-askit — 1.14.27 (was 1.14.24)
-
Version 1.14.27 a71b71
-
Fixed answer(s) Dutch translation d2124a
-
Version 1.14.26 8374b0
-
Merge pull request #74 from swissup/fix/issue-73-i18n-dictionary
Regenerate i18n dictionary, fix uncollectable and stale phrases ef2944 -
Regenerate i18n dictionary, fix uncollectable and stale phrases
`en_US.csv` had drifted well behind the source: 106 rows against the 196
phrases `i18n:collect-phrases` finds in the module. Regenerated it and
brought every locale file back in line with it.
Source fixes that had to come first:
- `listing.phtml` built the answer counter label as
`__($collection->getSize() == 1 ? 'answer' : 'answers')`. The phrase
collector cannot resolve a ternary inside `__()`, so it emitted the
literal `answer' 'answers` as a key — which every locale file then
faithfully translated, while the real `answer` and `answers` strings
stayed untranslatable. Moved the ternary outside the `__()` calls.
- `"id" is incorect` -> `"id" is incorrect` (CreateVote.php).
- `MassAssign.php` used `Assign to...` where the UI component label says
`Assign to ...`, producing two dictionary keys for one label. Unified
on `Assign to ...`.
Dictionary changes:
- `en_US.csv` regenerated from `i18n:collect-phrases`, 106 -> 196 rows.
This also drops the stray quoting on `It is helpful` and `Not much
help`, whose values were ` "It is helpful"` and ` "Not much help"` —
rendered on the frontend complete with the quotes.
- Removed 13 dead keys from every locale (`Hint`, `Askit Items`,
`New Askit Question`, `Answer Information`, `You saved.`, the
`${ $.$data.text }` UI-component leftovers, the misspelled
`We can't find a answer to delete.`, and the mangled
`answer' 'answers`).
- `fa_IR.csv`: removed a duplicate `Not much help` row whose value was
double-quoted, and folded the stray ` answer` key into `answer`.
- Normalised the leading space on the `asks` / `about` / `answer` /
`answers` values — the templates already prepend it.
- All files rewritten fully quoted.
Every locale now parses to exactly two columns per row, carries no
duplicate keys, and contains no key absent from `en_US.csv`.
Translating the newly exposed phrases is left to the translators;
coverage per locale is currently 44-49%.
Refs #73
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0c1b32 -
Merge pull request #72 from swissup/fix/issue-71-locale-csv
Fix malformed "Answer" row in locale files, add cs/sk/hu/ro 8b3f84 -
Fix malformed "Answer" row in locale files, add cs/sk/hu/ro
The "Answer" entry had an unclosed quote in 18 of 19 locale files:
"Answer,Answer
fgetcsv merged that line with the next one, so both "Answer" and
"Entity Type" were lost in every affected locale. Closing the quotes
restores them.
Also:
- add cs_CZ, sk_SK, hu_HU and ro_RO translations
- add the missing "Plain text only. HTML is not allowed.",
"Submit Question" and "Name" keys to en_US, de_DE, it_IT and nl_NL
Translations contributed by @dimdimich in #71. Over-escaped quotes
("""" instead of "") in the submitted hu_HU and ro_RO files were
corrected before merging. fa_IR was already well-formed and is
left untouched.
Fixes #71
Closes #70
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0381cd -
Version 1.14.25 d0112b
-
Merge pull request #69 from swissup/fix/issue-68-null-array-offset
Fix null array offset deprecation on new product form (#68) 1a7529 -
Fix null array offset deprecation on new product form
modifyData() wrote $data[$productId] where $productId is null for a
new (unsaved) product, triggering on PHP 8.5:
Deprecated Functionality: Using null as an array offset is
deprecated, use an empty string instead
Guard against a null product id and return $data unchanged, mirroring
the existing guard in modifyMeta(). current_product_id is meaningless
for a product that does not exist yet, so skipping the write is safe;
editing an existing product is unaffected.
Fixes #68
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 0fd6d0
swissup/module-breeze-ai — 1.4.0 (was 1.0.7)
-
chore(release): 1.4.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0bcf86 -
Merge pull request #89 from breezefront/claude/relaxed-shirley-d523bc
feat(backup): restore values overwritten by an AI run (#8) 1593f6 -
test(backup): cover the selection path and grid columns; honest run date (#8) 551936
-
Merge pull request #90 from breezefront/fix/composer-constraints
fix(composer): tighten dependency constraints (breezefront/breeze-ai#1) 976098 -
fix(openai): stop advising an upgrade PHP 8.1 cannot install
The TypeError branch told the user to update openai-php/client to ^0.19.
Only 0.10.x reaches it, and 0.10.x is also the only branch PHP 8.1 can
install: 0.11 and up require PHP 8.2. On the stores that actually hit
this, the advice was impossible to follow.
Name the PHP requirement instead of a client version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e3f75f -
fix(composer): tighten dependency constraints (breezefront/breeze-ai#1)
- php: "^7.4|^8.0|^8.1|^8.2" collapsed to ">=7.4 <9" anyway, since ^8.0
already covers 8.1-8.4. It also claimed support for PHP 7.4/8.0, which
cannot work: openai-php/client 0.10.x requires PHP ^8.1 and 0.19.x
requires ^8.2, so no version resolves below 8.1. Now "^8.1", matching
swissup/module-breeze.
- magento/framework: "*" guarded nothing. Dropped, as in module-breeze
and module-breeze-integrations.
swissup/module-core stays "*" — it is pulled in transitively and no
specific core version is required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b725ac -
fix(backup): declare restore isolation, strict capture, scope-true wording (#8) ad050c
-
fix(backup): re-check selections, capture and write together (#8) d54dbe
-
fix(backup): row action bypasses the mass-action filter, keep uncaptured video columns (#8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12ba05 -
fix(backup): keep restores retriable, recorded and correctly scoped (#8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0fb862 -
fix(backup): one capture per field, per-commit counts, gated mass action (#8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7ed888 -
feat(backup): restore values overwritten by an AI run (#8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3ec08b -
Merge pull request #88 from breezefront/claude/beautiful-liskov-50c01d
feat(usage): log tokens, cost and duration per AI call (#86) 6aba08 -
fix(usage): say when the ledger stops recording, distrust stored rates (#86) 784abb
-
fix(usage): escape separators so a value cannot forge a field (#86) 085672
-
fix(usage): price the model that answered, keep one line per call (#86) 4ac0c0
-
fix(usage): count reasoning tokens, keep sub-cent charges (#86) 7549fb
-
fix(usage): never infer a rate the ledger cannot stand behind (#86) a856a8
-
test(usage): cover the log wiring, not just the writer (#86) 0117c1
-
feat(usage): log tokens, cost and duration per AI call (#86) 128d10
-
Version 1.3.0 050f99
-
Merge pull request #83 from breezefront/fix/ai-buttons-overlap-name-82
fix(admin): stop AI buttons overlapping long field values (#82) 3348b6 -
fix(admin): lay AI actions beside field, not over it (#82) 3eb55f
-
fix(admin): stop AI buttons overlapping long field values (#82) 1a4854
-
Merge pull request #81 from breezefront/feat/editor-chat-panel
feat(content-builder): chat panel in the editor for building and refining a page (#64) 0932fb -
fix(content-builder): give the message textarea an accessible name b48efb
-
fix(content-builder): ground every request in the live working copy
pageContext() only supplies the page's title and id, so a first chat
message had no representation of the page's actual content, and manual
edits made between AI replies were silently overwritten by the last
frozen AI document. Every request now seeds or refreshes its document
turn from contentBuilder.getComponents() instead. Also drops historical
image attachments from the request — maxBytes is sized for one
screenshot under post_max_size, and replaying every prior attachment on
each follow-up can push the aggregate body past that limit. 9ca7e3 -
fix(content-builder): drop a stale AI response after a page switch (#64) 371c91
-
refactor(content-builder): share the chat loop between the modal and the editor panel (#64)
Extracts turn storage, chat-bubble rendering, and copy-to-clipboard into
chat-core.js, used by both chat.js's field-targeted modal and
bcb-chat-panel.js's docked panel. The request/response cycle stays local
to each caller — they talk to different skills with different payload
and response shapes, and forcing that through one function would trade
duplication for a worse, branchier abstraction.
Fixes two bugs the duplication had introduced in bcb-chat-panel.js: the
Copy button was never bound (only chat.js's copy of the handler existed)
and a stale "type a message" error could survive a valid follow-up
submission. 1ab342 -
fix(content-builder): keep the generated document in follow-up history, guard double-submit a48469
-
feat(content-builder): chat panel in the editor for building and refining a page (#64)
Docks a conversational panel into the Content Builder editor's tree panel
via a mixin on bcb-panel.js. Every reply is applied straight into the
working copy through content-builder.js's applyAiComponents(), and every
request is sent persist:false so nothing reaches the draft until the
admin uses the editor's own Save/Publish. 9e66ae -
feat(content-builder): add non-persisting build endpoint and editor chat config (#64)
Adds options.persist=false to Controller/Adminhtml/Page/Build so a request
can return generated components without writing the Content Builder draft.
Extracts vision-model selection out of BuildFromScreenshot into a shared
VisionModelProvider, and adds Block/Adminhtml/Editor/AiChatConfig plus its
layout/template to seed the editor route with the same config. 6eca5e -
Merge pull request #80 from breezefront/claude/focused-payne-b60751
fix(content-builder): reproduce every band the screenshot shows (#74) a84559 -
fix(content-builder): word the band warnings for the request that was sent (#74)
Four defects found reviewing the review fixes.
The count-mismatch warnings hardcoded "The reference design was read as ...",
but the mismatch branch runs whether or not a screenshot was attached — the
prompt asks for a band count on every build, not only on the image path. A
text-only brief that came back over- or under-segmented therefore told the
merchant about a reference design they never sent, on the build note and in
the Content Builder editor. The flag that decides whether to warn now decides
what the warning calls the thing the page was counted against.
The band-wrapper warnings borrowed ComponentValidator's "root[N]" label while
numbering a different list. The validator indexes the raw model output, where
a dropped node still consumes its index; the audit sees the list after
validation. One dropped band was enough to put two warnings saying "root[0]"
in one list, pointing at two different bands. They are numbered as bands now.
readScreenshot() read and base64-encoded the file before ImageValidator got to
apply MAX_IMAGE_BYTES, so an oversized design export cost about 2.3x its size
in memory to reach a verdict available from filesize(). The admin path is
unaffected: the browser posts base64 and no file is read.
FiveBandMock::drawProductRow() took $bottom and never used it, so that band
alone would not follow a change to the band split in draw() — in the one file
whose docblock claims the band boundaries are readable in source. 685502 -
fix(content-builder): keep the band count when the reply carries prose (#74)
Three defects found reviewing the band-audit change.
The prompt says "no prose" and models add it anyway, which is why
jsonCandidates() exists at all. It tries the widest "[...]" run before
"{...}", correct while a bare list was the asked-for shape. With an envelope
whose "unsupported" is omitted and "components" last, that run is exactly the
components array: it parses, wins, and the band count is thrown away. The
audit then reported "the AI did not report how many bands it saw" for a reply
that did report it. A list is now held as a fallback and the remaining
candidates are still tried for an envelope, so a reply that really is a bare
list still resolves to the first list.
spreadStackedSlots() decided a band was stacked from the count of the first
slot alone. "children" written as a flat list of nodes rather than a list of
slots lands there with the node itself as the first slot, and its own keys are
counted as sibling blocks — {"type": ..., "props": ...} counts 2 in a
two-column band and was shredded across the slots, replacing the accurate
short-slot warning with a repair that never happened. Every entry now has to
look like a node.
render.php ignored the file_put_contents result and printed "(0 bytes)" with
status 0 on an unwritable path, sending whoever reproduces a measurement to
look at the build command instead. ee671e -
fix(content-builder): reproduce every band the screenshot shows (#74)
Shown a five-band reference design, gpt-4o-mini returned a document whose
columns bands were fakes: the container had the right type and the right
column count, but every card sat in its first slot, so the band rendered as
one column with the rest empty. Measured over ten runs of the committed mock,
that happened in nine of them.
Three changes, in order of how much they hold:
- ComponentValidator spreads a container whose whole band was written into
its first slot, when the arithmetic leaves no other reading. Prompting
against this only half worked; the repair is unambiguous.
- The prompt asks for {"bands", "unsupported", "components"} instead of a
bare array, spells out the slot shape, and names the columns-versus-
product_grid choice. A bare array is still accepted.
- BandAudit compares the band count the model reports to what it emitted and
reports the difference, plus any band the catalog cannot express. It
reports and does not retry: a second round trip would double the cost of
every build to re-roll an answer a small model is as likely to get wrong
again, and the missing band cannot be synthesised locally.
Measured with the mock and expected shape now under Test/Fixture, driven
through the new --screenshot option. Ten runs each, gpt-4o-mini:
before: 5 bands x5, 6 bands x4, 1 hard failure; 9/9 mis-slotted
after: 5 bands x10, 0 mis-slotted, 8/10 with both columns containers
gpt-4o gets the slots right with either prompt, so the mis-slotting was
model capacity and the prompt closes the gap on the small model. 0e5f35 -
Merge pull request #79 from breezefront/fix/build-note-page-id-type
fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7faf89 -
fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7f6aaa
-
Merge pull request #78 from breezefront/claude/priceless-turing-a344ac
fix(config): offer model names the providers still serve (#73) 21eee6 -
Merge pull request #77 from breezefront/claude/jovial-chatelet-3447d7
feat(content-builder): report what the validator repaired (#72) d23f45 -
feat(content-builder): report what the validator repaired (#72) 4b47e4
-
fix(content-builder): drop a corrupt build note instead of reporting a clean build (#72) e3994a
-
fix(gemini): redact api key from error, notice for retired models (#73) 922d51
-
fix(config): offer model names the providers still serve (#73) b1963c
-
Merge pull request #76 from breezefront/claude/lucid-kepler-a7ce73
fix(content-builder): keep authoring placeholders out of generated copy (#71) 6a5b87 -
fix(content-builder): keep required sample copy on the malformed-value path (#71)
fallbackValue() blanked any authoring placeholder, so a required copy prop the
model sent as an array — quote.text, in the fixture — came out empty, which is
the broken component the omitted-prop path deliberately avoids. The rule now
lives in one predicate used by both paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8a6461 -
fix(content-builder): keep authoring placeholders out of generated copy (#71)
The catalog's "default" attribute does two jobs: the value a renderer needs
when a prop is unset, and the sample text the editor drops into a fresh
component. The validator filled both, so "Discover Something New" shipped as
the heading of three FAQ answers. Copy-typed props whose default reads as
prose are now stored blank — blank, not absent, because the templates read
`$props['heading'] ?? '<sample>'` — and the prompt marks them VISIBLE-TEXT
instead of quoting the sample text that taught the model to echo it.
A list-valued prop is blanked as an empty list, so an unusable faq_items no
longer falls back to the two sample questions. Tabs keeps its declared titles:
the container counts its slots from them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 00e837 -
Merge pull request #75 from breezefront/claude/amazing-lichterman-a86181
fix(content-builder): keep the list props the model wrote (#70) 819b38 -
fix(content-builder): keep every tab title paired with its slot (#70)
Dropping an unusable title compacted the list while normalizeChildren()
truncates slots from the end, so ["Shipping", "", "Returns"] over three
populated slots rendered "Returns" above the middle slot's content and threw
the last one away.
A list of strings is positional — its container pairs slot N with entry N —
so an unusable entry is now replaced where it stands, taking the declared
default's title for that position ("Tab 2"), which is what the container's
renderer pads a short title list with anyway. Entries that carry their own
content, like the FAQ items, have no slot to stay paired with and are still
dropped.
An empty list now says so rather than reporting that none of its zero
entries was usable. efb774 -
fix(content-builder): read a keyed object as one list entry (#70)
decodeList() accepted any array, so a keyed object counted as a list: an FAQ
item the model sent unwrapped — {"q": ..., "a": ...} instead of [{...}] —
was iterated field by field, turning the question and the answer into two
bogus items, each with the other's text as its question.
Only a real list is a list now. A keyed object carrying at least one declared
field is wrapped as the single entry it is, so the question the model wrote
survives; one carrying none falls back to the default with a warning naming
the fields that were expected. 71d179 -
fix(content-builder): keep the list props the model wrote (#70)
The catalog asks the model for the Tabs titles and the FAQ items as JSON
arrays, then ComponentValidator coerced every non-scalar prop back to its
default — so a generated FAQ arrived with its questions replaced by "Tab 1"
/ "Tab 2" / "Tab 3", and the Tabs slot count fell back to the default three,
dropping any further answer with it.
A prop whose declared default is a JSON array is now validated as a list
instead of discarded: entries are cleaned one by one and written back in the
JSON-string shape the editor stores and the renderers decode, so the slot
count follows the titles the model actually wrote. The default is only
reached when nothing usable is left, and the warning then names the reason.
Recognised by the shape of the declared default rather than by prop name, so
the FAQ accordion's question/answer items are covered by the same path. a4765b -
Merge pull request #69 from breezefront/claude/stoic-yonath-58797a
fix(content-builder): drop fake "spacing" prop from catalog prompt 7db871 -
fix(content-builder): drop fake "spacing" prop from catalog prompt
The margin/padding summary line started with the group label instead of
prop names, so the model read "spacing" as a prop and emitted it on every
component that had one. Render one line per value range, leading with the
real names, which also stops claiming -200 as the minimum for paddings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> beb335 -
chore: drop stray .b64 scratch files from screenshots dir
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> e5e228 -
docs: add screenshots to test guide, correct scenarios after live testing
Vision-model dropdown pre-filters server-side (10.6 unreachable via UI as
written), Remove+re-attach confirmed clean (10.9). Found and filed a real
draft-persistence bug in module-breeze-content-builder (#31) — re-run builds
report success but don't survive a fresh page reload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8062ec -
docs: add test scenarios and how-to-test guide for Content Builder screenshot import
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8e6e8d -
Merge pull request #67 from breezefront/feat/value-backup
feat(backup): record values before AI writes 70f7cc -
fix(backup): skip ambiguous staging rows 27e298
-
fix(backup): guard capture reads, batch pruning 19f666
-
feat(backup): record values before AI writes 21d8f6
-
Merge pull request #66 from breezefront/test/bulk-selection-regression
test(bulk): pin the scope a run resolves to b69352 -
test(bulk): seed the products the scope cases run against
Copilot was right on both counts: the cases borrowed whatever the
catalogue happened to hold, and the exclusion case skipped itself whenever
no two SKUs shared a five-character prefix. A regression check that goes
quiet when the surrounding data does not suit it is not a guard.
Three products with a prefix unique to the run are created in setUp and
removed in tearDown, so every case is deterministic and none can skip. The
prefix also gets its own case, since the two filtered cases rest on it
selecting this test's products and nothing else.
Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour now turns five
of the six red, up from four. The unfiltered select-all stays green, which
is correct - that path is the legitimate one and behaved the same before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e82c74 -
test(bulk): pin the scope a run resolves to
The selection fault has landed three times. 15c1e7c introduced an
all_selected flag driven by allSelected(), #31 closed it in June, and
c13907e swapped in excludeMode() and brought it straight back - Magento
raises either flag by itself once the checked rows cover the rows the grid
is showing. Both attempts only ever argued about the client-side flag,
while the resolver went on loading the collection with no filters at all.
The E2E spec covers the request the wizard posts. Nothing covered what
that request then resolves to, which is the number that decides how many
products get rewritten. These cases do, against the live catalogue: an
explicit selection, a genuine select-all, a select-all narrowed by a
filter, exclusions inside a filtered set, and an empty selection.
Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour turns four of
the five red, the exception being the unfiltered select-all - which is
correct, since that case is the legitimate path and behaved the same
before.
Refs #61
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8e8b71 -
Version 1.2.0 634a46
-
Merge pull request #63 from breezefront/feat/build-page-from-screenshot
Build a Content Builder page from a screenshot 680ede -
fix(vision): close the second round of review points on #63
The one that mattered: `options['brief']` skipped the fix from the previous
commit entirely. It leaves the history untouched by design, so an
alternating conversation that ended on a user turn got our prompt appended
after it — the exact pair Anthropic answers 400 to, reachable from REST
where the caller supplies both a brief and a history. The prompt is now
folded into a trailing user turn instead of following it, which keeps that
turn's words rather than dropping them. The class docblock claimed "a
history that already alternates keeps alternating"; it does now.
`testSendsTheHistoryFollowedByTheGeneratedPrompt` was pinning the broken
shape — two user turns, asserted as correct — so its history now ends on
an assistant turn and the folding case has tests of its own.
The block hid itself for every reason a build could fail except the two
the controller actually enforces. A role holding `Magento_Cms::page` but
not `Swissup_BreezeAi::index` or `Magento_Cms::save` saw a working-looking
button whose every click came back as the login page, which the modal can
only report as an expired session.
Also from the review: the modal kept the previous brief when reopened, so
it would have ridden the next screenshot unseen; an in-flight FileReader
could re-arm a screenshot the admin had already removed, now invalidated
by a token that `clearScreenshot()` moves on; the drop zone gained
`role="button"` and the brief and model controls gained real labels, since
a placeholder is not one and the select had no accessible name at all.
A warning-free build no longer redirects on its own. The draft is saved by
then but the CMS form behind the modal is not — this request never touched
it — so a title or URL key typed before opening the modal was silently
lost. The editor is offered as a link in both outcomes.
635 unit, 18 integration, 30 e2e green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3072d9 -
fix(vision): answer the three points Copilot raised on #63
The docs described a cap the code stopped using. `BuildFromScreenshot`
reads `post_max_size`, and its own comment explains why `getMaxFileSize()`
is the wrong ceiling for a base64 string in a text field — the doc still
named `getMaxFileSize()`, which is exactly the sort of mismatch someone
debugging a 413 would be misled by.
The drop zone carries `tabindex="0"` but only answered to a click, so it
could be tabbed to and not opened. Enter and Space now trigger the picker,
with the same guard the click handler needs: the remove control sits
inside the zone and answers to both keys, and swallowing them there would
leave it dead to the keyboard.
`getVisionModels()` is asked for four times while one CMS page form
renders — `canShow`, `getConfigJson`, and `getSelectedModelId` from inside
it — and each ask loads every model to read its metadata. Memoized for the
life of the block, which is one request.
631 tests green; 29 e2e, the new keyboard case among them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2e6454 -
test(e2e): assert the selection contract the wizard actually posts
33d5e97 replaced the all_selected flag with Magento's own mass-action
contract — one of selected or excluded, plus the grid namespace and the
filters that were on screen — and updated SaveTest, but the Playwright spec
kept asserting the flag that no longer exists. Red on master since then.
The assertion now names what the fix was for: the filters travel with the
selection, which is what stops an exclude-mode Select All from widening past
the rows the grid was showing. f34845 -
feat(vision): build a Content Builder page from a screenshot
PR #55 landed the whole image path — MessageInterface::getImages(),
ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
nothing used it. Every caller of build_page passed an empty history, and no
UI produced an images list. This gives the admin a way in.
A button in the Content Builder fieldset of the CMS page form opens a modal:
attach a screenshot, optionally type a brief, pick a vision-capable model.
The file is read with FileReader and posted as a data URI inside
history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
runs the skill, saves the result through DraftWriter, and answers with the URL
of the editor the admin reviews it in.
Synchronous rather than an option on the bulk wizard: a reference screenshot
is a per-page input, and one screenshot over N pages produces N near-identical
layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
all three providers are used exactly as PR #55 built them, unchanged.
BuildPage now lifts images off the user turns they arrived on and puts them on
the prompt turn it builds, consuming the last user turn as the brief. That
fixes an existing fault: Model\Provider\Claude passes the message list through
unmerged and Anthropic refuses two user turns in a row, so the old
array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
fallback was the thing being used.
PromptBuilder gains a conditional Reference design block. Two of its five
rules carry the weight: without the band-per-row line the model answers with a
flat component list and the row-wrapping rule loses to the picture, and
without the ignore-brand-and-colours line it copies the screenshot's hex
values into every colour prop and the draft clashes with the merchant's theme.
Verified end to end against gpt-4o-mini on a real storefront screenshot:
six row-wrapped bands, no validator repairs, draft readable in the editor.
With a brief added, the structure holds and the copy follows the brief rather
than transcribing the screenshot.
540 -> 621 unit tests, all green. 9d8854 -
test(mcp): list build_page among the tools the server exposes
McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
with four tools. build_page joined the skill pool in 1bf01f6, and every skill
in the pool is exposed as a tool, so the integration suite has been red since
then. Nothing about the server changed — only what the test expects. f8588c -
test(e2e): cover the build-from-screenshot modal
The one surface unit tests cannot reach. It exists because of a bug that got
all the way to review: the file input is a child of the drop zone, so the
synthetic click jQuery sends it bubbles back into the zone's own handler and
recurses until the stack blows — the picker never opens and only drag and drop
works. "clicking the drop zone opens the file picker" is the guard for that.
Also covers the pre-flight refusal of a non-image, that the refusal sends no
request, the preview and remove cycle, and that a reopened modal carries none
of the previous run's state.
Stops short of pressing Build draft: that would spend a real provider call.
Skips itself where the control legitimately hides — no Content Builder, or no
configured model that reads images. 10934b -
test(mcp): list build_page among the tools the server exposes
McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
with four tools. build_page joined the skill pool in 1bf01f6, and every skill
in the pool is exposed as a tool, so the integration suite has been red since
then. Nothing about the server changed — only what the test expects. 60d8c7 -
feat(vision): build a Content Builder page from a screenshot
PR #55 landed the whole image path — MessageInterface::getImages(),
ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
nothing used it. Every caller of build_page passed an empty history, and no
UI produced an images list. This gives the admin a way in.
A button in the Content Builder fieldset of the CMS page form opens a modal:
attach a screenshot, optionally type a brief, pick a vision-capable model.
The file is read with FileReader and posted as a data URI inside
history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
runs the skill, saves the result through DraftWriter, and answers with the URL
of the editor the admin reviews it in.
Synchronous rather than an option on the bulk wizard: a reference screenshot
is a per-page input, and one screenshot over N pages produces N near-identical
layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
all three providers are used exactly as PR #55 built them, unchanged.
BuildPage now lifts images off the user turns they arrived on and puts them on
the prompt turn it builds, consuming the last user turn as the brief. That
fixes an existing fault: Model\Provider\Claude passes the message list through
unmerged and Anthropic refuses two user turns in a row, so the old
array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
fallback was the thing being used.
PromptBuilder gains a conditional Reference design block. Two of its five
rules carry the weight: without the band-per-row line the model answers with a
flat component list and the row-wrapping rule loses to the picture, and
without the ignore-brand-and-colours line it copies the screenshot's hex
values into every colour prop and the draft clashes with the merchant's theme.
Verified end to end against gpt-4o-mini on a real storefront screenshot:
six row-wrapped bands, no validator repairs, draft readable in the editor.
With a brief added, the structure holds and the copy follows the brief rather
than transcribing the screenshot.
540 -> 621 unit tests, all green. c53c71 -
Version 1.1.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3bc1cd -
Merge pull request #60 from breezefront/fix/bulk-selection-scope
fix(bulk): scope a run to what the grid was actually showing 49b5f5 -
fix(bulk): scope a run to what the grid was actually showing
A client selected one product and the wizard queued a run over all 714.
The submit path decided "everything is selected" from the grid
excludeMode flag, and core turns that flag on by itself as soon as the
checked rows cover the rows the grid currently holds - filtering down to
a single product and ticking it is indistinguishable from pressing Select
All. The server then made it worse: the all_selected branch loaded the
entity collection with no filters at all, so even a deliberate select-all
meant the whole catalogue rather than the filtered set. Either fault
alone produces the wrong count.
Both go away by speaking Magento's own mass-action contract. The wizard
now posts selected or excluded alongside the grid namespace, filters and
search, exactly as Magento_Ui/js/grid/massactions does, and
BulkEntityIdResolver runs the collection through MassAction\Filter. An
exclude-mode selection resolves against the filters that were on screen,
so the accidental flag can no longer widen the scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 33d5e9 -
Version 1.1.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f29385 -
Merge pull request #59 from breezefront/fix/model-form-provider-options
fix(admin): serve the model form's options from the option sources 72aff2 -
fix(admin): serve the model form's options from the option sources
The model select is repopulated in the browser whenever the provider
changes, and the map it read from was a hand-maintained copy inside
swissup_breezeai_model_form.xml. Adding GPT-5.6 updated OpenAiModel and
the switcher rules but not that copy, so Sol, Terra and Luna could not be
picked at all - and the run wizard's gpt-5.6-terra default could never
match a saved row.
ProviderModels builds the map from the same option sources the form
already declares, injected through FormDataProvider::getMeta(). One list
per provider, and registering a provider in di.xml is enough for its
models to appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 936696 -
Merge pull request #58 from breezefront/fix/temperature-zero
Stop dropping a temperature of 0 d79542 -
fix(providers): stop dropping a temperature of 0
Claude and Gemini read the value with an assignment inside the condition:
if ($temperature = $config->getExtra('temperature')) {
which is a truthiness test, and `(bool) 0` is false. An admin setting 0 — the
value you pick precisely to make output deterministic — had it silently discarded
and got the provider's own default instead, 1.0 on Claude. No error, no warning,
just different output than asked for.
Compared against null and '' now, so 0 reaches the request and an unset field
still sends nothing. OpenAI was already correct via `?? DEFAULT_TEMPERATURE`.
Found by exercising the admin form rather than by reading: the field only became
settable when the extra_config registry landed, and both existing provider tests
covered temperature at 0.5 and 0.7, never at 0. Tests now cover 0 and unset for
both providers, and fail on the old code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> da9be6 -
Merge pull request #57 from breezefront/refactor/extra-config-registry
One registry for the extra_config fields 40cddc -
Merge pull request #55 from breezefront/feat/vision-image-input
Vision foundation: let a message carry images, encode them per provider fd09a0 -
refactor(config): one registry for the extra_config fields
Which fields live in the extra_config JSON was written down three times: in the
save controller, in the form data provider, and in the form XML. Nothing failed
when they disagreed — a field missing from the save list is silently not stored,
one missing from the read list comes back blank on every edit. Adding `vision`
meant three coordinated edits with no test that would catch a missed one.
`temperature` is what that cost. All three providers read it; neither list
carried it and the form had no field, so it could not be set from the admin at
all. It has a field now, and the notice says what the ranges are and that GPT-5
and the o-series ignore it.
Model/ModelConfig/ExtraFields owns the keys and how each normalises. Types
matter here: a blank submission clears the key, an int at zero clears it — no
token cap means anything at zero — but a float at zero is kept, because zero is
a meaningful temperature and dropping it would silently restore the provider
default for someone asking for deterministic output.
The form XML still declares its own fields: a field needs a label, a notice and a
control that no list can supply. What moved is which keys travel in and out.
Needs setup:di:compile — both consumers gained a constructor argument.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1dc4b -
refactor(vision): cut ImageBuilder to the shape callers send
Five accepted spellings of the same input for one call site: fromBinary had no
caller at all, and fromArray took `data_uri` beside `url`, `mimeType` beside
`mime_type`, and a data URI hidden in `data`. Each was a guess about a caller
that does not exist — no skill attaches an image yet, and no JS sends one.
What is left is what GenerateService passes: `{mime_type, data}`, or a data URI
under `url` — the key OpenAI's own image_url part uses, so it is the one a caller
already has a name for. fromDataUri and the base64 check became private; the
public surface is fromArray, which is the only method anything calls.
94 lines to 90, and 4 public methods to 1. Adding a shape back is cheap once a
caller wants it; carrying four unused ones is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 66ac12 -
fix(vision): address the review findings on this PR
VisionSupport keeps only the `vision` config key and reads ModelMetadata for
what the name means, so its two prefix tables and its copy of matchesPrefix are
gone — 116 lines to 70. The o-series can no longer be listed here as text-only
while being listed there as reasoning.
ImageValidator collects every image, checks the count, and only then decodes.
The cap was the last rule tested, so an over-cap request paid for N base64
decodes to be refused, and was refused for whichever image happened to be
malformed first rather than for being over cap.
ImageBuilder accepts and discards parameters between the mime type and
`;base64`. A `charset` an exporter felt like adding says nothing about image
bytes, and refusing the URI over it refused a readable image.
GenerateService::toMessage() no longer claims Web API hands history over as
arrays. It does not: /V1/breezeai/generate declares MessageInterface[] and
ServiceInputProcessor dies with "Cannot instantiate interface" before the service
runs, because the interface has no DI preference. The caller that really passes
arrays is the admin controller, which decodes the body itself. History over REST
predates image input and is not fixed here; docs/DEVELOPMENT.md says so instead
of implying the route works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> dd6371 -
feat(providers): let a message carry images, and encode them per provider
A message gains getImages(): ImageInterface[] alongside its string content,
and each provider encodes those images into its own wire format — an
Anthropic base64 image block, an OpenAI image_url part holding a data URI,
a Gemini inline_data part. Content stays a string, so a text-only turn
produces the same request body it did before and every existing caller of
`new Message($role, $content)` keeps working.
Two things guard the send. ImageValidator enforces the limits the three
providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
request, user turns only — and VisionSupport answers whether the model a
call is routed to reads images at all, from the model name plus a `vision`
override on the model row (the new Image Input field). AiRequest runs both
before reaching the provider, so an image sent to a text-only model fails
with one sentence naming the model rather than a provider 400. Callers that
want to know in advance ask AiRequest::supportsVision().
ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
a decoded request payload — into an ImageInterface, and GenerateService
uses it so history posted over the wire can carry images too.
No skill attaches an image yet; this is the layer under that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ad9747 -
fix(data): document every Message constructor parameter
Magento reads @param tags positionally — TypeProcessor::getParamDocBlockTag()
indexes the tag list with the parameter's own offset — so a block documenting
only $images typed $role from the images tag and had no tag to return for
position 2. Reflecting the constructor to build a Message from request data
died there with a TypeError before any of our own validation ran.
The two missing tags are the whole fix. A test pins the invariant, since the
next parameter added to this constructor would silently reintroduce it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b793a0 -
Merge pull request #56 from breezefront/refactor/model-metadata
One table for what a model name means eb79b5 -
fix(model): correct the model facts the merge exposed
Putting both axes in one table made three disagreements visible:
o1 reads images. It was absent from the image-input list while present in the
parameter-set one, which is the drift this table exists to stop. o1-mini and
o1-preview genuinely do not, so they say so as longer prefixes.
gpt-4-vision-preview — where vision on GPT-4 started — matched nothing and read
as text-only, because the list carried gpt-4-turbo and gpt-4o but not the model
named for the capability.
claude-4 was dead weight: Claude 4 and later put the tier first, as in
claude-opus-4-5, and those already match claude-opus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f113ee -
refactor(model): read model facts from one table
RequestBody carried three prefix lists and VisionSupport (in #55) carried two
more, each with its own copy of matchesPrefix. Nothing tied them together, so
they drifted: the parameter-set list knew about o1 and the image-input list did
not, and no test could catch it because neither list knew the other existed.
ModelMetadata holds the facts once, keyed by provider and name prefix. Every
prefix a name starts with contributes, shortest first, so a longer prefix
overrides a shorter one and states only what differs — o3-mini says it takes no
image and inherits its parameter set from o3. That ordering is what removes the
separate lists of exceptions each consumer used to keep beside its main list.
A per-row sampling or vision value stays with the consumer that reads it: the
row overriding the name is config, not a fact about the model.
Behaviour is unchanged — every existing RequestBody test passes untouched, and
the table reproduces the old lists name for name, o1's missing image support
included. Correcting that is the next commit, kept separate so this one reads
as the move it is.
supportsVision() has no caller on master yet; #55 is the caller and rebases
onto this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 891642 -
feat(providers): let a message carry images, and encode them per provider
A message gains getImages(): ImageInterface[] alongside its string content,
and each provider encodes those images into its own wire format — an
Anthropic base64 image block, an OpenAI image_url part holding a data URI,
a Gemini inline_data part. Content stays a string, so a text-only turn
produces the same request body it did before and every existing caller of
`new Message($role, $content)` keeps working.
Two things guard the send. ImageValidator enforces the limits the three
providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
request, user turns only — and VisionSupport answers whether the model a
call is routed to reads images at all, from the model name plus a `vision`
override on the model row (the new Image Input field). AiRequest runs both
before reaching the provider, so an image sent to a text-only model fails
with one sentence naming the model rather than a provider 400. Callers that
want to know in advance ask AiRequest::supportsVision().
ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
a decoded request payload — into an ImageInterface, and GenerateService
uses it so history posted over the wire can carry images too.
No skill attaches an image yet; this is the layer under that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 659e74 -
Version 1.1.0 9aeb4e
-
Merge pull request #54 from breezefront/feat/bulk-run-model-and-system-prompt
feat(bulk): pick the model and the framing per run, and pin them to it cab8ed -
fix(bulk): keep the closing script tag out of a JS comment
The comment warning about a prompt body holding a closing script tag
contained one, which ended the block early and dumped the rest of the
script onto the page as text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1f118 -
feat(bulk): pick the model and the framing per run, and pin them to it
The bulk wizard ran on whatever the configuration said at the moment each
operation was picked up, with no way to steer the model or tell it what
kind of writing was wanted. The client rated the German output 4/10 and
asked for both: a model per run, and a free-text instruction that reaches
the model as framing rather than as part of the product text.
Two things had to be true for that to mean anything.
The framing has to arrive in the system channel, ahead of the content.
It does: the skills put it in a system message, and the providers already
agree that a caller's system message wins over their built-in default.
Where a run brief is present the translate prompt drops its own "you are
a professional e-commerce translator" opening — that line answers the one
question the brief exists to answer — while keeping the mechanical rules
about numbers, SKUs and HTML, which are output format rather than
persona.
And both have to hold still. A batch of a few thousand products drains
over hours, so resolving configuration per operation means editing the
default model halfway through silently splits the batch across two
models with nothing in the result to say which got which. The model and
the prompt are now decided when the run is scheduled and travel in the
operation payload, which is already immutable once written — no queue
schema change. Even leaving the dropdown alone pins a concrete row:
"the default model" is a moving target.
The model ID is a pointer and the row behind it stays editable, so the
provider and model name ride along as a snapshot and the consumer stops
rather than bill a model nobody chose. The API key is deliberately not
snapshotted — rotating one has to reach the runs already queued.
For Generate this reverses an existing precedence: a model passed in now
beats the one bound to the prompt. Step 5 tells the operator which model
the run will use, and a per-prompt model overriding that would make the
screen lie. Nothing outside the wizard passes one, so the prompt's own
model still decides everywhere else.
Two small stores back the UI. Prompts gain a type, so a run brief can be
saved in the existing library without turning up in the per-field
dropdowns; rows written before the column are field prompts, which is all
the module could store then. And one row per admin remembers what the
wizard was last run with, so it opens on that instead of an empty form —
a preference, not a reference, which is why it carries no foreign key to
the model table: a model since deleted is simply not offered.
Operations queued before any of this carry none of the new keys and keep
running on the default model.
setup:upgrade applies the schema; Claude, Gemini and the bulk consumer
take new constructor arguments, so setup:di:compile is needed as well.
./bin/test unit — OK (454 tests, 901 assertions)
phpcs --standard=Magento2 — 0 errors on every touched file
Schema verified against a real database, not only declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> fcb9d8 -
fix(providers): agree on what a caller's system message means (#53)
* fix(providers): agree on what a caller's system message means
The three providers disagreed. OpenAI lets a caller-supplied system message
replace the built-in framing; Claude and Gemini filtered it out of the
conversation and then sent their own constant anyway, so the instruction was
read, dropped, and never reached the model. The same prompt therefore meant
different things depending on which model it happened to be assigned to — and
silently, because nothing errors when a system message is discarded.
Nothing sends one today, so this fixes no live failure. It removes a trap: the
first skill that reaches for its own framing would have got it on OpenAI and
lost it on the other two, and the symptom is bad output rather than an error.
`Model\Provider\SystemMessage` now resolves the framing for all three — the
caller's, or the built-in default when the caller supplied none. Where it goes
still differs per API: inline in `messages` for OpenAI, the top-level `system`
field for Claude, `system_instruction` for Gemini. More than one system message
is joined in order rather than reduced to the first, since Claude and Gemini
have a single field for it and dropping the rest would lose instructions.
The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
points at the shared constant so it keeps working for anything referencing it.
Claude and Gemini take a new constructor argument, so this needs
`setup:di:compile`.
./bin/test unit — OK (409 tests, 810 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(openai): resolve the framing through the shared rule, not a copy of it
The previous commit gave Claude and Gemini a shared SystemMessage but left
OpenAI deciding for itself in buildMessages(). Only the default string was
shared, so the two paths could still drift: several system messages were
joined on Claude and Gemini and left scattered through the conversation on
OpenAI, and any later change to resolve() would have reached two providers
out of three.
OpenAI now calls resolve() as well. The framing still travels as the first
entry of `messages` because that is what the endpoint takes; the caller's
system messages are lifted out of the conversation and folded into it, so
the instruction is sent once rather than twice.
resolve() also stops treating a blank system message as one supplied. An
empty string is not framing, and passing it through meant Gemini receiving
an empty `system_instruction` part — which it refuses — where before the
constant always went out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 2c716b -
fix(openai): resolve the framing through the shared rule, not a copy of it
The previous commit gave Claude and Gemini a shared SystemMessage but left
OpenAI deciding for itself in buildMessages(). Only the default string was
shared, so the two paths could still drift: several system messages were
joined on Claude and Gemini and left scattered through the conversation on
OpenAI, and any later change to resolve() would have reached two providers
out of three.
OpenAI now calls resolve() as well. The framing still travels as the first
entry of `messages` because that is what the endpoint takes; the caller's
system messages are lifted out of the conversation and folded into it, so
the instruction is sent once rather than twice.
resolve() also stops treating a blank system message as one supplied. An
empty string is not framing, and passing it through meant Gemini receiving
an empty `system_instruction` part — which it refuses — where before the
constant always went out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7ddc5f -
fix(providers): agree on what a caller's system message means
The three providers disagreed. OpenAI lets a caller-supplied system message
replace the built-in framing; Claude and Gemini filtered it out of the
conversation and then sent their own constant anyway, so the instruction was
read, dropped, and never reached the model. The same prompt therefore meant
different things depending on which model it happened to be assigned to — and
silently, because nothing errors when a system message is discarded.
Nothing sends one today, so this fixes no live failure. It removes a trap: the
first skill that reaches for its own framing would have got it on OpenAI and
lost it on the other two, and the symptom is bad output rather than an error.
`Model\Provider\SystemMessage` now resolves the framing for all three — the
caller's, or the built-in default when the caller supplied none. Where it goes
still differs per API: inline in `messages` for OpenAI, the top-level `system`
field for Claude, `system_instruction` for Gemini. More than one system message
is joined in order rather than reduced to the first, since Claude and Gemini
have a single field for it and dropping the rest would lose instructions.
The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
points at the shared constant so it keeps working for anything referencing it.
Claude and Gemini take a new constructor argument, so this needs
`setup:di:compile`.
./bin/test unit — OK (409 tests, 810 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 435eb3 -
fix(openai): send the parameter set the model family accepts (#52)
* fix(openai): send the parameter set the model family accepts
Every request carried temperature, frequency_penalty and presence_penalty,
and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
family and the o-series refuse all four on the same endpoint: the two
penalties are rejected outright even at 0, temperature takes nothing but
its default, and the token cap was renamed to max_completion_tokens. A
request built the old way comes back 400, so those models did not degrade
on this provider — they failed every call.
Nobody hit it because the model dropdown stopped at gpt-4o-mini and
reaching a newer one meant typing it into the custom field. The client on
the translation project asked for gpt-5.6-terra, which is where this
starts to matter.
The body is now built by a small class of its own. The provider builds its
HTTP client inline and cannot be exercised without a network, which left
the part that has to be right per model family with no test at all; that
part is now pure and covered.
Family is decided by the model name, with a sampling extra-config key to
override it: an Azure deployment or a proxy can serve GPT-5 under a name
that says nothing, and prefix matching would send it what it refuses.
reasoning_effort and verbosity are passed through where they are
understood, so the effort knob is reachable without another release.
A caller-supplied system message now replaces the built-in one instead of
being appended after it — two system messages in one request left the
model to pick. No caller sends one yet.
The three GPT-5.6 tiers are in the model dropdown, so the client's model
is a selection rather than a hand-typed string.
phpcs --standard=Magento2 reports 0 errors on the touched files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(openai): gate the reasoning knobs per family and expose them in admin
The first pass treated GPT-5 and the o-series as one family. They are not:
`verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
rejects `reasoning_effort` as well. Sending either to a model that refuses it
reproduces the 400 this branch set out to remove, so the knobs are now offered
per family while the token-cap rename and the dropped sampling parameters stay
common to both.
The knobs themselves had no way in. `sampling`, `reasoning_effort` and
`verbosity` were readable only by hand-editing the extra_config JSON, which
made the escape hatch for unnamed Azure and proxy deployments unreachable in
practice. All three are now fields on the model form, saved into and read back
out of extra_config alongside max_tokens.
Also:
- honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
matching what the Claude and Gemini providers already do
- read `sampling` without casting, so a non-string value in the JSON cannot
raise an array-to-string conversion
- note in the class docblock that an unrecognised model name falls back to the
GPT-4 parameter set, so the next family will need its prefix added here
./bin/test unit — OK (401 tests, 802 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 37217f -
fix(openai): gate the reasoning knobs per family and expose them in admin
The first pass treated GPT-5 and the o-series as one family. They are not:
`verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
rejects `reasoning_effort` as well. Sending either to a model that refuses it
reproduces the 400 this branch set out to remove, so the knobs are now offered
per family while the token-cap rename and the dropped sampling parameters stay
common to both.
The knobs themselves had no way in. `sampling`, `reasoning_effort` and
`verbosity` were readable only by hand-editing the extra_config JSON, which
made the escape hatch for unnamed Azure and proxy deployments unreachable in
practice. All three are now fields on the model form, saved into and read back
out of extra_config alongside max_tokens.
Also:
- honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
matching what the Claude and Gemini providers already do
- read `sampling` without casting, so a non-string value in the JSON cannot
raise an array-to-string conversion
- note in the class docblock that an unrecognised model name falls back to the
GPT-4 parameter set, so the next family will need its prefix added here
./bin/test unit — OK (401 tests, 802 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7d1b03 -
fix(openai): send the parameter set the model family accepts
Every request carried temperature, frequency_penalty and presence_penalty,
and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
family and the o-series refuse all four on the same endpoint: the two
penalties are rejected outright even at 0, temperature takes nothing but
its default, and the token cap was renamed to max_completion_tokens. A
request built the old way comes back 400, so those models did not degrade
on this provider — they failed every call.
Nobody hit it because the model dropdown stopped at gpt-4o-mini and
reaching a newer one meant typing it into the custom field. The client on
the translation project asked for gpt-5.6-terra, which is where this
starts to matter.
The body is now built by a small class of its own. The provider builds its
HTTP client inline and cannot be exercised without a network, which left
the part that has to be right per model family with no test at all; that
part is now pure and covered.
Family is decided by the model name, with a sampling extra-config key to
override it: an Azure deployment or a proxy can serve GPT-5 under a name
that says nothing, and prefix matching would send it what it refuses.
reasoning_effort and verbosity are passed through where they are
understood, so the effort knob is reachable without another release.
A caller-supplied system message now replaces the built-in one instead of
being appended after it — two system messages in one request left the
model to pick. No caller sends one yet.
The three GPT-5.6 tiers are in the model dropdown, so the client's model
is a selection rather than a hand-typed string.
phpcs --standard=Magento2 reports 0 errors on the touched files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3b7370 -
Merge pull request #51 from breezefront/fix/category-translate-source-language
fix(translate): name the source language when translating categories db336b -
docs(translate): tighten the source-store comment
The docblock read like a changelog entry for 1.0.11. Keep the reason the
source store is handed to the skill, drop the history around it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f63c1b -
fix(translate): name the source language when translating categories
1.0.11 changed the translate prompt to name both languages ("translate
from Danish to German") because a weak model, told only the target, left
a good part of the text in the source language. The bulk consumer was
only half-converted: translateProducts() passes source_store_id, but
translateCategories() still calls the skill with the attribute and the
target store alone.
The source store was already used to load the category, so the values fed
to the model were right — only the prompt was missing the "from" half.
Category translations therefore kept running on the pre-1.0.11 prompt and
came back partly untranslated, the exact symptom 1.0.11 was released to
cure. Nothing surfaced it: the skill treats source_store_id as optional
and silently omits the clause when it is absent.
The unit test now asserts the whole options array rather than accepting
any call, so the next field added to it cannot go missing the same way.
phpcs --standard=Magento2 reports 0 errors on the touched files, the same
as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6871f1 -
Merge pull request #50 from breezefront/fix/bulk-empty-field-panels-v2
fix(bulk): do not offer a task whose field panel has nothing in it acd541 -
fix(bulk): do not offer a task whose field panel has nothing in it
The wizard decides what step 2 may offer by asking whether a step 4 panel
exists for the pair. A panel could exist and still be empty: Generate
renders a row per attribute only when a prompt is bound to it, so with no
prompts configured the panel is a bare fieldset. The check said yes, the
operator picked Generate, and step 4 came up blank with a Run button that
never enabled — the dead end the panel check was meant to close, reached
from inside the panel instead of around it.
Prompts are now resolved once up front and an attribute without any is
dropped, which makes "the panel has fields" knowable before rendering.
A panel with no fields is not rendered, so the existing check reports the
truth for it. Translate gets the same treatment for symmetry, though its
field list is only empty on entity types nobody can select today.
That leaves step 2 able to come up with nothing at all in it, so it now
says why instead of showing an empty box.
phpcs --standard=Magento2 reports 0 errors on the touched file, the same
as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 392330 -
Merge pull request #48 from breezefront/fix/bulk-save-skill-entity-validation
fix(bulk): refuse a skill the queue cannot run on the chosen entity type 24f2e1 -
Merge pull request #49 from breezefront/fix/bulk-empty-field-panels
fix(bulk): do not offer a task whose field panel has nothing in it ecfa84 -
Merge pull request #46 from breezefront/fix/bulk-wizard-skill-filter
fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word 87738f -
fix(bulk): refuse a skill the queue cannot run on the chosen entity type
BulkConsumer::execute() routes on (skill, entity type) pairs and throws on
anything it has no handler for, but the scheduling controller accepted
whatever was posted. A request for a pair the consumer cannot route —
generate on cms_page, say, which the wizard hides but the form will still
carry — was answered with "has been scheduled", split into operations, and
failed one by one where only the bulk log would show it.
Save now checks the pair before it schedules anything, so the operator is
told no while still looking at the screen. The check runs before the entity
ids are resolved, which also spares a full-catalog id query for a request
that was never going to run.
QueueRoutes holds the pairs rather than inlining them, because the
consumer's routing table is the real authority and the two must not drift:
QueueRoutesTest reads the consumer's match arms and asserts they are
exactly the declared list.
It is deliberately not SkillSupport. That asks whether a skill can act on
an entity type at all and answers from getSupportedEntityTypes(); Generate
claims cms_page for the inline editor and is right to, but there is no
generate/cms_page route in the consumer. What a skill can do and what the
queue can carry out are separate facts, and only the second one decides
what may be scheduled.
370 unit tests pass. phpcs --standard=Magento2 reports 0 errors on the
touched files, and Save.php carries the same 10 warnings as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 600476 -
Merge pull request #47 from breezefront/fix/cms-page-translate-scope
fix(translate): stop writing CMS page translations into a category 219f60 -
fix(bulk): do not offer a task whose field panel has nothing in it
The wizard decides what step 2 may offer by asking whether a step 4 panel
exists for the pair. A panel could exist and still be empty: Generate
renders a row per attribute only when a prompt is bound to it, so with no
prompts configured the panel is a bare fieldset. The check said yes, the
operator picked Generate, and step 4 came up blank with a Run button that
never enabled — the dead end the panel check was meant to close, reached
from inside the panel instead of around it.
Prompts are now resolved once up front and an attribute without any is
dropped, which makes "the panel has fields" knowable before rendering.
A panel with no fields is not rendered, so the existing check reports the
truth for it. Translate gets the same treatment for symmetry, though its
field list is only empty on entity types nobody can select today.
That leaves step 2 able to come up with nothing at all in it, so it now
says why instead of showing an empty box.
phpcs --standard=Magento2 reports 0 errors on the touched file, the same
as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1fe13b -
fix(translate): refuse an unsupported entity type in the skill itself
Narrowing getSupportedEntityTypes() to product and category told the truth
but gated nothing. Three paths still reached a CMS page:
- McpServer::executeSkill() passes entityType straight to GenerateService;
the schema enum is a hint to the client, not a check. translate +
cms_page + targetStore>0 went through Translate::execute(), which took
the attribute list from the input field pool — and the pool still knows
the cms_page fields — translated the text, saved nothing, and reported
success. A silent no-op instead of an error.
- The CMS page form kept its "AI Translate" buttons. Block\Adminhtml\Js
hands translate.js every input field of the entity type, and the form
has no store switcher, so currentStore is always 0: every click ran
translateForAllStores() and died on the new save guard.
- That death cost one AI request. translateForAllStores() calls the skill
first and saves after, so the provider was paid before anything refused
the entity.
Translate::execute() now asserts the entity type against its own
getSupportedEntityTypes() right after resolving it — before the provider
call, so a refused entity costs nothing, and for every caller at once.
saveAttributeForStore() keeps its throw as the last line of defence.
SkillSupport answers "can this skill act on this entity type" for UI code;
Js publishes skills.translate and translate.js renders no buttons when it
is false. Generate still gets the full field list.
Js gained a constructor argument — setup:di:compile.
364 unit tests pass. phpcs --standard=Magento2 reports no errors on the
touched files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 551992 -
refactor(bulk): give the step 4 panel lookup one home
The panel query behind the wizard's "can this task run here" check was
written out three times over. wizardCanRun() and hasFieldSelection() ran
byte-identical selectors, each with its own empty-argument guard, so a
change to how a panel is addressed had to be made in more than one place
to hold.
findPanel() is now that one place, and both callers read as what they
ask: does a panel exist, and does the panel that exists have a selection.
The Build Landing Page panel also rendered unconditionally, though step 1
offers CMS pages only when the Content Builder is installed. Without it
the panel was unreachable markup in every admin; it now follows the same
condition as the content type it serves.
No behaviour change. 358 unit tests pass; phpcs --standard=Magento2
reports the same 0 errors as before on the touched file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d13ac -
fix(translate): stop writing CMS page translations into a category
translateForAllStores() routes every non-product entity through
saveAttributeForStore()'s else branch, which loads a *category* by the entity's
id. For a CMS page that id is a page_id, so translating page 8 for all store
views wrote its translated meta_title onto category 8 — on this install, "New
Luma Yoga Collection". A silent write into an unrelated record, no error, no
trace.
Extending the branch is not the fix either: cms_page has nowhere to put a
per-store-view value. The table holds one row per page — title, meta and content
are not store-scoped — and the store assignment lives in cms_page_store. A
translated page in Magento is a separate page. So Translate stops claiming
cms_page, which also narrows the MCP tool's entityType enum, and
saveAttributeForStore() raises on anything it does not explicitly handle instead
of guessing: entity ids collide freely across tables, and guessing corrupts.
Generate keeps cms_page — that path is real. GenerateService::loadEntity()
already resolves pages through PageRepository, so the AI button works on the SEO
fields of the CMS page form. content_heading joins them: plain text, same
mechanics as product-name, and the one obvious gap. title and identifier are
left out on purpose (identifier drags url rewrites behind it), and content
belongs to the Build Landing Page skill, which writes the whole document.
Bulk is unaffected: BulkConsumer never handled generate/cms_page and still does
not, so the wizard offers Build Landing Page alone for CMS pages.
Verified against the compiled DI:
cms_page fields content_heading, meta_title, meta_keywords, meta_description
generate [product, category, cms_page]
translate [product, category]
build_page [cms_page]
360 unit tests pass, including one that pins the corruption: translating a CMS
page for all store views now raises and never touches the category repository.
phpcs --standard=Magento2 reports no errors on the touched files. afe08f -
fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word
Picking CMS Pages still listed Generate Content and Translate: both declare
cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
in the previous commit let them through. Choosing either landed on an empty step
4 with no step 5 and no Run button — the exact dead end this branch set out to
remove, just reached by another route.
A skill's own entity list says nothing about whether this screen can drive it.
The wizard now asks the only thing that matters: does a step 4 panel exist for
this (content type, task) pair. That is the condition for not dead-ending, and it
stays true as panels are added or removed.
getSkillEntityTypes() existed only to feed the old check and is dropped with it.
Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
schedules "AI Build Landing Page for 1 CMS page". 92afa6 -
fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word
Picking CMS Pages still listed Generate Content and Translate: both declare
cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
in the previous commit let them through. Choosing either landed on an empty step
4 with no step 5 and no Run button — the exact dead end this branch set out to
remove, just reached by another route.
A skill's own entity list says nothing about whether this screen can drive it.
The wizard now asks the only thing that matters: does a step 4 panel exist for
this (content type, task) pair. That is the condition for not dead-ending, and it
stays true as panels are added or removed.
getSkillEntityTypes() existed only to feed the old check and is dropped with it.
Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
schedules "AI Build Landing Page for 1 CMS page". 7413c9 -
Merge pull request #45 from breezefront/feat/bulk-cms-landing-page
feat(bulk): build landing pages from the AI Bulk Action wizard eeabe7 -
fix(bulk): file the generated page draft under the scheduling admin
BCB drafts are per user: Content/Load reads getDraft($pageId, 0, $currentUserId)
and the collection filters on equality, so a draft written for anyone else is
invisible in the editor. bcb_page_content.user_id is also a foreign key on
admin_user, so the placeholder 0 the consumer was passing failed the insert
outright — every build_page operation died with an integrity violation and no
draft was ever stored. The unit tests missed it because DraftWriter is mocked.
The scheduling admin's id now travels in the payload and DraftWriter requires it,
rejecting 0 before touching the repository. Save refuses to schedule a page build
when the current user cannot be identified, rather than queueing work that cannot
land.
Alongside that:
- CMS Pages is only offered when the Content Builder is installed. Without it
nothing can store the document, so the wizard used to run the whole batch
through the AI only to fail on save. Step 1, the grids and the LISTING map are
all derived from ViewData::getEntityTypes() now.
- Step 4's heading follows the picked task: "Describe the page" for Build Landing
Page instead of the field-list copy, which still said "Select attributes such
as name, description, meta title".
- Operator messages use real names: "AI Build Landing Page for 3 CMS pages"
rather than "AI Build_page for 3 cms_page(s)".
- Step 4 warns that the run replaces the current draft on every selected page.
- Dropped the unused is_active column from the CMS page grid's select.
Tests: DraftWriterTest covers both guards and the write, SaveTest covers the
payload and the messages, BulkConsumerTest asserts the user id reaches the
writer and that a payload without one fails the operation. 359 pass; phpcs
--standard=Magento2 reports no errors. e8f294 -
feat(bulk): build landing pages from the AI Bulk Action wizard
Step 2 listed every registered skill, so Build Landing Page was offered for
products and categories. It declares cms_page and no attributes, leaving step 4
empty and step 5 unreachable — the wizard dead-ended with no error.
Tasks are now filtered by getSupportedEntityTypes() against the content type
picked in step 1, and CMS Pages joins that step so the skill has a real path:
a brief instead of the per-attribute field list, a CMS page grid, and no store
view step (Content Builder documents are store-agnostic).
The generated document is stored as the page's Content Builder draft. The BCB
page flag is left alone: turning it on with nothing published renders a blank
storefront page, so enabling the editor and publishing stay manual. The BCB
dependency stays soft — the repository is resolved by name behind
interface_exists(), like the component catalog reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> afe7f0 -
Version 1.0.13 43bc3d
-
Merge pull request #44 from breezefront/chore/mcp-graphql-review-followups
chore(mcp): document graphql guard params, look the tool up by name 4aade7 -
chore(mcp): document graphql guard params, look the tool up by name
Follow-ups from the review of #43.
`assertReadOnly()`, `getDefinitionKeywords()` and `skipStringValue()` were
missing `@param` annotations, which `phpcs --standard=Magento2` reports on
`Model/McpServer.php` (18 warnings before, 15 after — the rest are pre-existing).
`testGraphqlToolIsDocumentedAsReadOnly` picked the graphql tool out of
`tools/list` by its position in the array. The position shifts as soon as another
skill is registered, so the test would start asserting against the wrong tool
rather than failing for a reason anyone could read. It now looks the tool up by
name and asserts the entry exists.
No behaviour change. ./bin/test unit: OK (339 tests, 680 assertions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e3bc09 -
Merge pull request #43 from breezefront/fix/mcp-graphql-read-only
fix(mcp): reject mutations in the read-only graphql tool 84c5b5 -
fix(mcp): reject mutations in the read-only graphql tool
The `graphql` MCP tool is documented as "read-only catalog access" in its own
tool description, in the README and on breezefront.com, but `McpServer::graphql()`
forwarded the `query` argument to the storefront GraphQL endpoint verbatim. No
auth header is sent, so the document executes in guest scope — and guest scope is
not read-only: Magento accepts `createEmptyCart`, `addProductsToCart`,
`setGuestEmail` and the rest of the guest cart surface there. An agent connected
over MCP could therefore write to the store through a tool advertised as read-only.
`assertReadOnly()` now refuses any top-level definition other than `query` and
`fragment` before the request goes out.
The operation type is read from the definition position of the document rather
than by searching for the word "mutation", which would be both bypassable and
wrong. `getDefinitionKeywords()` walks the document tracking brace and paren
depth, skips comments and string literals (including block strings), and records
the keyword that opens each top-level definition — treating the anonymous
shorthand `{ ... }` as `query`. So a leading newline, a `# comment`, an operation
name, variable definitions with object default values or a directive cannot
smuggle a keyword past it, and `mutation` used as a field name, an alias or
inside a string argument is not mistaken for an operation.
What the check covers: every definition in the document, so a mutation appended
after a legitimate query is rejected regardless of `operationName`; mutations and
subscriptions; type-system definitions (an allowlist, so unknown keywords are
refused rather than passed through).
What it does not cover: field-level read restrictions — any data guest scope can
already read stays readable, which is what "read-only" claims and no more. It is
not a GraphQL validator either; a malformed document (an unterminated string, say)
may still be forwarded, and the endpoint rejects it before executing anything.
`/llms.txt` is a separate case. Its "read-only" claim describes the store's own
storefront `/graphql`, which Magento serves and this module cannot restrict, so
the wording is corrected there instead of enforced: the endpoint is unauthenticated
and does accept guest mutations, and the file documents read queries only.
Also corrects the assumption in docs/plan/mcp-server.md that seeded this —
"Catalog GraphQL is public/read-only, so no auth risk" conflated public with
read-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a63cf6 -
Version 1.0.12 e71d2a
-
Merge pull request #42 from breezefront/feat/ai-landing-page-builder
feat: build a landing page from a text brief (build_page skill) ee8d3c -
fix(content-builder): report every repair and honour the config cache
Review of #42 found four gaps between what the skill promises and what it
does.
Silent repairs. The validator clamped out-of-range numbers and padded short
children arrays without saying so, while the skill states that every repair
lands in "warnings" — a merchant saw a page that did not match the brief with
no way to tell why. coerceNumber() now reports both the clamp and the
fallback-to-default, and normalizeChildren() reports a short slot list. An
omitted "children" stays silent: that is a deliberately empty container, not a
mis-count.
Grid rule keyed off a component name. rejectNestedContainers() compared
$definition['name'] against the literal 'grid', so a rename in the Content
Builder, or a second grid-like container, would drop the rule without a sound.
Catalog::allowsOnlyLeafChildren() now derives it from the "slots" metadata of
the layout preset, which is what actually makes those slots CSS grid areas.
Catalog cache outlived the config cache type. Catalog took
Framework\App\CacheInterface, so it kept a 24-hour copy even with the config
cache disabled, while the Content Builder's own registry re-read the XML.
Editing bcb_components.xml left the two disagreeing until a full cache:flush.
Switched to Framework\Config\CacheInterface, matching ComponentRegistry.
JSON extraction cut on the wrong brace. The old slice ran from the earliest
'[' or '{' to the latest ']' or '}', so prose carrying a stray brace ("the
layout (a {row} first): [...]") produced a broken span. jsonCandidates() now
offers the fenced block, the bare reply and the widest run of each bracket
kind separately, and decode() keeps the first that parses.
Also covers Console\Command\BuildPage, which had no test: option handling,
the stdout/stderr split that keeps `> page.json` valid, and the failure exit
code.
Suite: 305 tests, 578 assertions. phpcs --standard=Magento2: 0 errors.
Verified end to end against the real 14-component registry — the repaired
document renders through Swissup\BreezeContentBuilder\Model\ContentRenderer
and now emits 14 warnings where it previously emitted 8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c725c4 -
feat(content-builder): build a landing page from a text brief
Adds a build_page skill that composes a Breeze Content Builder document
out of the components a store actually has installed, so a merchant can
describe a landing page instead of assembling it section by section.
The skill returns {"components": [...], "warnings": [...]} for a CMS page
and saves nothing — the caller decides whether to store the draft through
the Content Builder's own save endpoint.
No dependency on Swissup_BreezeContentBuilder: CatalogReader collects
bcb_components.xml through Module\Dir\Reader, which is a file name rather
than a class, so the AI module still installs on its own and the skill
simply reports that no components are available.
Model output cannot be trusted as-is. ComponentValidator checks it against
the same catalog the prompt was built from — dropping unknown component
types and props, snapping select values back into their option lists,
clamping numbers to their declared range, and resizing the children arrays
of containers to the slot count the type requires. Every repair is
reported so the merchant can see why the result differs from the brief.
Slot arithmetic lives in Catalog::resolveSlotCount and mirrors the admin
editor's component-factory.js, keeping one source of truth for tabs, grid
presets, columns and rows.
Also exposed over MCP (brief instead of attribute) and through
bin/magento breezeai:page:build, which prints the document on stdout and
the warnings on stderr. --show-catalog dumps the catalog exactly as the
model receives it.
Vision input and URL screenshots are deliberately out of scope here: this
lands the part that had to work first, which is a model emitting a
schema-valid document.
Refs #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1bf01f -
Merge pull request #41 from breezefront/fix/translate-source-language-prompt
fix(translate): translate from source language + module-logger dev logging (1.0.11) e7a8f5 -
Version 1.0.11
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> dca39c -
fix(translate): translate from the selected source language, not just to target
Bulk translation only told the model the TARGET language ("Translate to German"),
never the source. Weak models (e.g. gpt-4o-mini) then left many words in the
source language when translating Danish→German — option titles and values on the
target store came out as a Danish/German mix, while English→German looked clean.
- Pass the Source Store View through to the Translate skill (source_store_id) so
the prompt is "translate from <source> to <target>".
- Strengthen the prompt: instruct the model to translate EVERY word and leave
nothing in the source language, while preserving numbers/SKUs/HTML.
- Applies to EAV attributes, custom option titles/values, and media labels.
Optional query/response logging now goes through swissup/module-logger
(require-dev), gated by the BREEZEAI_TRANSLATE_DEBUG constant — a no-op unless
that module is installed and the constant is set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 520ff4 -
test(translate): drop error-silencing @ on mkdir in media scope test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 78d334 -
Version 1.0.10
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 300ae5 -
fix(translate): write media labels to target store without full product save
MediaTranslator persisted translated media gallery labels / video metadata with
a full ProductRepository::save() of the product loaded at the target store. That
full save re-processes the product's custom options through Magento core, and
saving a product loaded at a non-default store propagates its option/value titles
into other store views — silently overwriting the SOURCE store's custom option
titles. Symptom: after a bulk translation that includes media fields (image /
gallery labels, video), the source (e.g. Danish) store view shows English custom
options, while the target store is translated correctly.
Write the translated label / video metadata directly into the target store's
rows of catalog_product_entity_media_gallery_value and
catalog_product_entity_media_gallery_value_video (update-or-insert, inheriting
position/disabled and provider/url/metadata from the default-store row). No
ProductRepository::save(), so custom options and other stores are untouched —
the media counterpart of the 1.0.9 OptionTranslator fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 6f808f -
test(translate): integration guard for media translation vs option scope
Reproduces the bug where translating media gallery labels corrupts the custom
option titles of other store views. Creates a product with a media image plus a
drop-down custom option carrying source-store title overrides, runs
MediaTranslator for the gallery label, and asserts the option titles are
unchanged in every store. Fails on the full-save implementation, passes with the
direct target-store media write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 5236d5 -
Version 1.0.9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 195f5b -
test(translate): integration guard for custom option store scope
Runs the real BulkConsumer against the live DB with a stubbed Translate skill.
Fixture mirrors the client's data: a drop-down custom option with per-store-view
titles (source + target) and no default (store 0) row. Asserts translation lands
on the target store scope only — target gets the translated titles, default
scope stays absent, source store is unchanged. Fails on the old
emulation/ProductRepository::save() path, passes with the direct target-store
write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 58a666 -
fix(translate): write custom option titles to target store scope only
Bulk custom-option translation round-tripped through ProductRepository::save()
under target-store emulation. Two problems:
1. Scope leak — Magento core Option::_saveValueTitles()/Value::_saveValueTitles()
loop over [DEFAULT_STORE_ID, storeId]. When an option has no default-scope
title row, the store-0 iteration inserts the current (translated) title into
store 0, and the default store view (which resolves titles via the store-0
fallback) then displays the translation. Emulation cannot prevent this — it
happens inside the same save().
2. Dropped writes — a product reloaded via getById() does not carry
canSaveCustomOptions=true, so in a queue-consumer context save() silently
ignores the option-title changes.
Write each translated option/value title directly into the target store's row
via insertOnDuplicate on catalog_product_option_title /
catalog_product_option_type_title. The unique (option_id, store_id) /
(option_type_id, store_id) keys make it a single-row upsert that never touches
store 0 and does not depend on the product save persisting options — the custom
option counterpart of the 1.0.7 category fix that replaced
CategoryRepository::save() with CategoryResource::saveAttribute().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 86c13e -
Version 1.0.8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> c293f3 -
Merge pull request #38 from breezefront/fix/llms-txt-markdown-links
fix(llms-txt): emit Markdown links so Agentic Browsing audit passes a24f22 -
fix(llms-txt): emit Markdown links so Agentic Browsing audit passes
PageSpeed's Agentic Browsing "Agent Accessibility" audit failed with
"llms.txt does not follow recommendations - File does not appear to
contain any links." The generated llms.txt had a valid H1 and summary
but wrote every endpoint as plain text / inline code, so it contained
zero Markdown links.
Convert the GraphQL endpoint, schema introspection, and BreezeAI
content-generation endpoint lines into Markdown links. Add a regression
unit test asserting the output contains at least one [text](url) link.
Fixes #37
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 49206f
swissup/module-breeze-layout-builder — 1.2.0 (was 1.0.0)
-
Version 1.2.0 24fff8
-
Added Contact Us page support d64e46
-
Config option for upcoming TaxVAT switcher in theme header 7f31bd
-
Version 1.1.1 5a2bcc
-
Improved drag-and-drop in the components tree 05a7f1
-
Version 1.1.0 1bcf8f
-
Added installer cfe73f
-
Disable Header/Footer feature 544da0
-
Fix Header logo publishing 71e7dd
-
Fixed header/footer publishing and preview 237ea4
-
Moved header/footer configuration to separate page b9b4cb
-
WIP: header/footer configs save, apply, and restore 127237
-
Added special Header and Footer items with their layout containers adbb7b
swissup/module-easycatalogimg — 1.5.15 (was 1.5.14)
swissup/module-gdpr — 1.9.2 (was 1.9.1)
swissup/module-navigationpro — 1.19.3 (was 1.18.0)
-
Version 1.19.3 421267
-
Do not use `is_active` from remote entity when creating new item
Since we have new option `use_remote_include_in_menu`, use it instead
of syncing `is_active` on item creation. 7c215a -
Fixed not working ribbon for vertical menu 8fdb50
-
LESS 5.3.0 compatibility 922d05
-
Version 1.19.2 c00164
-
Fixed unresponsive browser when toggling 7000 categories ef21c1
-
Version 1.19.1 b7ca42
-
Added ability to select all items in "Mass Edit" form 6f9251
-
Version 1.19.0 cb840c
-
Added ability to respect "Include in menu" setting 879226
swissup/module-pagespeed — 1.19.0 (was 1.18.0)
-
Version 1.19.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 94fd39 -
fix(preload): cap font preloads and fix @font-face parsing (#117)
Font preloads had no cap and no notion of whether a face blocks rendering, while the url() parsing dropped most real-world forms and could aim a preload at a nonexistent host. Measured on a live Argento product page, 138 KB of preloaded fonts plus an eager 219 KB footer PNG stretched a 68 KB LCP JPEG into a 3.1 s download; removing them took LCP from 3.83 s to 2.11 s on Slow 4G.
- pagespeed/main/font_preload_limit (default 0, opt-in) caps the preloads per response
- pagespeed/main/font_preload_ignore skips urls by substring, for icon fonts
- candidates ranked by effective font-display: render-blocking first, then fallback, then swap; optional never preloaded
- url() values unquoted, fragments dropped, queries kept, hrefs mirroring the protocol-relative form the delivered css uses
- @font-face delimited and read on a comment-masked copy, with offset-based injection
- collection armed one-shot per process() call, so it no longer depends on caller order
Preload gained a Config constructor argument, so deploys need setup:di:compile.
Fixes #117 9a26a2 -
fix(preload): make font preloading from stylesheets opt-in (#117)
Default font_preload_limit goes from 2 to 0.
Fixing the url parsing means stores whose @font-face urls are quoted or
absolute — most of them — start getting font preloads they never had,
because the old code silently dropped those urls. Shipping a default of 2
would therefore add up to two High priority requests into the LCP window
on upgrade, which is the exact regression this branch exists to remove:
on the store that prompted it, 138 KB of preloaded fonts cost 1.7 s of
LCP. With font-display:swap injected the fonts do not block rendering
anyway, so the bandwidth buys little.
The budget and the ranking stay; an admin who knows which weight renders
above the fold raises it to 1 or 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6c93d -
fix(preload): make font collection one-shot per armed call (#117)
Sixth review pass pointed out that CssResolver calls process() without
ever calling setResponse(), so merged stylesheets never contribute
candidates — my claim that the ranking converges across every stylesheet
was wrong. Only the critical style blocks feed it.
Rather than widen collection, which would pull fonts out of every merged
stylesheet in a change meant to cap them, the gate is now honest and
deterministic. setResponse() arms exactly the next process() call, so a
bare process() never pushes. Left armed, whether a merged stylesheet
contributed depended on the order the two callers happened to run in for
a given request, which made the preload set vary between requests.
CriticalCss already chains setResponse() before each process() call, so
production behaviour is unchanged. The multi-call tests now re-arm the
same way, which also makes them mirror that caller.
Also pins the two remaining shapes of the mask's documented limit — a }
and a ; inside a quoted value — as misses rather than corruption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 95ecf9 -
docs(preload): record the comment mask's limit and pin its failure mode (#117)
The mask cannot tell a real comment from a comment opener inside a css
string or data uri, so a fake opener can blank a whole @font-face. That
limit is now stated at maskCssComments(), together with why a css
tokenizer is not worth carrying to read one descriptor.
The test pins the direction the failure takes: the face is skipped, so it
gets no injection and contributes no preload candidate, and the
stylesheet passes through unchanged. Skipping is acceptable; rewriting a
stylesheet we could not read is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 48133a -
fix(preload): delimit @font-face on a comment-masked copy (#117)
Fifth review pass.
A } inside a comment ended the face body early for the plain regex, so
@font-face{/* } */src:url(a.woff2)} yielded the body "/* " — the url was
never collected, and worse, the injection then ran
str_replace('/* ', 'font-display:swap;/* ') across the whole stylesheet.
The truncation predates this branch; the injection path made it harmful.
Comments are now blanked to spaces of the same byte length before the
faces are delimited, so a masked } cannot end a body while every offset
still points into the original text. Injection splices at those offsets,
from the end so earlier ones stay valid. That also fixes a second
injection bug: two identical face bodies in one stylesheet had their
swap injected by str_replace on the body text, which rewrote both and
then did it again on the next iteration.
Invalid duplicate descriptors no longer beat a valid one either. The
browser drops invalid declarations at parse time, so
font-display:optional;font-display:garbage stays optional; the last
valid value wins and auto applies only when nothing valid was declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7b87f1 -
fix(preload): validate the whole font-display value (#117)
Fourth review pass. The value pattern captured the leading alphabetic
token, so font-display: optional garbage read as optional and its
preload was suppressed — but the whole value is invalid css and the
browser falls back to auto, which blocks. Same for a descriptor written
with !important, which is dropped entirely.
The value is now taken up to the declaration terminator, trimmed, then
checked against the five valid keywords, so anything invalid resolves to
auto as the browser does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9ca057 -
fix(preload): read @font-face without its comments (#117)
Third review pass. A css comment may sit wherever whitespace may, so
font-family:A;/* keep */font-display:optional; hid the declaration from
the boundary matcher. The face then got a swap injected in front of a
declaration that stays effective, and was ranked as swap and preloaded
even though the author marked it optional.
Every read of a @font-face body now works on a comment-free copy, which
fixes the same class of bug in url extraction too: a commented-out
url(old.woff2) was being harvested as a preload candidate and could
spend the budget on a file the browser never requests. The injection
itself still targets the original text.
While there: a face declaring font-display twice resolved to the first
declaration, where the cascade takes the last.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d0f213 -
fix(preload): match font-display as a declaration, mirror scheme case (#117)
Second review pass found two more holes.
The injection guard tested for the substring "font-display" anywhere in
the @font-face body, so a family named after the descriptor or a font
path containing the word suppressed the swap injection and left the face
blocking. Both the guard and the value read now require the descriptor
at a declaration boundary — start of the body, or after a previous
declaration.
Scheme stripping accepted any spelling, but the css rewrite in process()
is a case-sensitive str_replace: HTTPS://host/f.woff2 stays absolute in
the delivered css while the preload became protocol-relative, which is a
different url on an http storefront. The href now mirrors that replace
exactly and keeps an unusual spelling absolute. The http(s) check also
moved off filter_var, which accepts file:// happily.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ba6b69 -
fix(preload): move the font budget to Preload and tighten parsing (#117)
Review follow-up. Four defects in the first pass:
Selection ran per process() call, but CriticalCss calls the improver
once per critical <style> block and the css resolver plugin once per
stylesheet. A swap face in the first block could spend the whole budget
before a render-blocking face in a later one was seen, and the same href
in two blocks consumed two slots. Candidates now accumulate in Preload
and the ranked selection happens in getAssets(), once, when every
stylesheet has been through the improver. Selection is pure, so
repeated getAssets() calls agree.
Deduplication kept the least urgent rank. Wrong: when one woff2 backs
two families, the family that blocks still blocks, so the file is
render-critical and the most urgent declaration has to win.
The font-display regex was case-sensitive and matched only the
alphabetic prefix of a value, so FONT-DISPLAY: OPTIONAL read as
render-blocking while the invalid optional-invalid read as optional and
was skipped. It is now case-insensitive, takes the whole token, and
resolves anything outside the five valid values to auto.
Preload hrefs forced https. process() rewrites absolute css urls to
protocol-relative, so on an http storefront the preload pointed at a
url the stylesheet never requests. Hrefs now mirror the delivered css:
protocol-relative for absolute and protocol-relative sources,
root-relative left alone, non-http schemes skipped.
Also adds declare(strict_types=1) to the new test file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8b22be -
fix(preload): cap font preloads and fix url() parsing (#117)
Improver pushed a preload for every woff2 it could parse out of a
stylesheet, with no cap and no check on whether the face blocks
rendering. Chrome fetches preloaded fonts at High priority, so each one
competes with the LCP image on the same connection and fetchpriority
cannot outrank it. Measured on a live Argento product page: dropping
138 KB of preloaded fonts (plus an eager 219 KB footer PNG) took LCP
from 3.83 s to 2.11 s on Slow 4G.
The url handling was broken in both directions. str_replace('//', '/')
ran before the validity check, so absolute and quoted urls were
silently dropped, while a root-relative url became https://<first path
segment> — a preload aimed at a host named "static". The cache-busting
query was stripped too, so a preload that did survive missed the css
request and the font downloaded twice.
- pagespeed/main/font_preload_limit (default 2, 0 disables) caps the
preloads per response, shared across every stylesheet processed
- pagespeed/main/font_preload_ignore skips urls by substring, for
decorative icon fonts
- candidates are ranked by effective font-display: render-blocking
first, then fallback, then swap; optional is never preloaded
- url() values are unquoted, fragments dropped, queries kept, and
root-relative hrefs left alone; hrefs are deduped
The budget lands together with the parsing fix on purpose: fixing the
parsing alone would start emitting the preloads that were being dropped
and ship the regression by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c16e23 -
Version 1.18.8 953e26
-
fix(preload): crossorigin="anonymous" is font-only and host-independent (#115)
crossorigin was added to any third-party resource hint based on host alone.
It switches the hint to a CORS-mode fetch, so a third-party script whose
server sends no Access-Control-Allow-Origin had its response blocked
(s.kk-resources.com/leadtag.js, #115). Even a permissive third party is hurt:
a CORS-mode hint never matches the no-cors <script> request, so the file is
fetched twice.
The host check was wrong in the other direction too — @font-face fetches in
CORS mode even from the store's own domain, so every same-origin font hint
was emitted without crossorigin and could not be reused by the real font
request.
crossorigin now depends on the asset type alone: fonts always, whatever host
they come from; scripts, styles and images never — they mirror the origin
element instead. An explicit crossorigin from the asset definition or from an
existing link is preserved; the rule only governs adding one. Applied in both
createPreloadLinkElement() and updatePreloadLinkInPlace(), so preload links
the theme or a tag manager already placed in <head> follow it too.
With the host out of the decision, isThirdPartySource(), getHost() and
getBaseUrlHost() are dead and removed, along with the StoreManagerInterface
constructor argument and the try/catch that only swallowed Laminas URI parse
errors.
Tests: 8 regression cases in AddLinkPreloadTest — third-party script, style
and image get no crossorigin; third-party and same-origin fonts keep it;
explicit crossorigin survives; existing in-head links follow the same rule.
Fixes #115
Closes #116 41be41 -
fix(preload): make font crossorigin host-independent (#115)
Review catch on the previous commit: @font-face fetches in CORS mode even
when the font sits on the store's own domain, so gating crossorigin on
"third-party host" left every same-origin font hint without the attribute
— the hint then cannot serve the real request and the font is downloaded
twice. That is the same failure this PR set out to remove, only pointed
at local fonts instead of third-party scripts.
crossorigin now depends on the asset type alone: fonts always, everything
else never. With the host no longer part of the decision, the whole
isThirdPartySource() / getHost() / getBaseUrlHost() chain is dead, and
with it the StoreManager dependency and the try/catch that only existed
to swallow Laminas URI parse errors.
- needsCrossorigin(string $as) — type-only decision
- Drop isThirdPartySource(), getHost(), getBaseUrlHost(), $baseUrlHost
- Drop the StoreManagerInterface constructor argument (DI resolves the
constructor by reflection, so no di.xml change) and the store mocks it
required in 28 test construction sites
- Drop the now-unreachable try/catch around both call sites
Tests: 2 more cases — a same-origin font hint, and a same-origin font
preload link already in <head>, both must keep crossorigin.
709 unit, 34 integration — all green. 225428 -
fix(preload): limit crossorigin="anonymous" to font hints (#115)
A third-party resource hint got crossorigin="anonymous" based on host
alone, with no regard for the asset type or for what the live tag asked
for. crossorigin switches the hint to a CORS-mode fetch, so for
s.kk-resources.com/leadtag.js — a script whose server sends no
Access-Control-Allow-Origin — the browser blocked the response outright.
Even a permissive third party is hurt: the CORS-mode hint never matches
the no-cors <script> request, so the file is fetched twice.
Only fonts are always fetched in CORS mode and therefore need the
attribute. Scripts, styles and images must mirror the origin element,
never invent it.
- Add CROSSORIGIN_TYPES = ['font'] and needsCrossorigin($as, $href)
- Use it in both createPreloadLinkElement() and
updatePreloadLinkInPlace(), so the rule also covers preload links the
theme or a tag manager already placed in <head>
- An explicit crossorigin from the asset definition or the existing link
is still preserved — the rule only governs adding one
The blanket rule dates back to e7a35a0 (2020), whose own comment noted
"add warning and more requests but increase score. why???".
Tests: 6 regression cases in AddLinkPreloadTest — third-party script,
style and image get no crossorigin; fonts keep it; explicit crossorigin
survives; existing in-head preload links follow the same rule. Three of
them reproduce the reported markup exactly against the old code.
Fixes #115 cc021a -
Version 1.18.7 d50f2d
-
chore: add repo-specific code-review skill for GitHub Copilot (#114)
GitHub's PR reviewer suggests adding an agent skill or MCP servers for
context-aware reviews. Give it one: the strategy-chain / patch-tracking
architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
matches, untracked DOM mutations) that a generic review won't know to
look for, on top of this repo's Magento 2 / phpcs conventions. 5021e3 -
chore: add repo-specific code-review skill for GitHub Copilot
GitHub's PR reviewer suggests adding an agent skill or MCP servers for
context-aware reviews. Give it one: the strategy-chain / patch-tracking
architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
matches, untracked DOM mutations) that a generic review won't know to
look for, on top of this repo's Magento 2 / phpcs conventions. 005217 -
fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036) (#113)
PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
against picking the wrong element by class alone, but SmartTagStrategy runs
earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
element whose attributes cleared a 70% similarity score, with no check for
a later, better (or exact) candidate.
Two product images in the same category grid share nearly every attribute
(class, style, loading, decoding, width, height, sizes, srcset) and differ
only in src/alt, comfortably clearing 70% similarity - so a patch meant for
the second image could land on the first one instead, reproducing the same
image-swap symptom the 1.18.3 fix was supposed to close.
SmartTagStrategy now scores every candidate and applies the best-scoring one
only when it isn't tied with another - same "lose the optimization rather
than corrupt the page" rule AttributeBasedStrategy already uses for class.
Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
first-match-wins behavior on a genuine tie, which is no longer correct. 0cf639 -
fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036)
PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
against picking the wrong element by class alone, but SmartTagStrategy runs
earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
element whose attributes cleared a 70% similarity score, with no check for
a later, better (or exact) candidate.
Two product images in the same category grid share nearly every attribute
(class, style, loading, decoding, width, height, sizes, srcset) and differ
only in src/alt, comfortably clearing 70% similarity - so a patch meant for
the second image could land on the first one instead, reproducing the same
image-swap symptom the 1.18.3 fix was supposed to close.
SmartTagStrategy now scores every candidate and applies the best-scoring one
only when it isn't tied with another - same "lose the optimization rather
than corrupt the page" rule AttributeBasedStrategy already uses for class.
Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
first-match-wins behavior on a genuine tie, which is no longer correct. e769d6 -
Version 1.18.6 9678b2
-
fix(test): resolve module and Magento root by walking up, not fixed depth (#112)
bin/test assumed the module sits exactly 5 levels below the docker root
and always tested the canonical vendor/swissup/module-pagespeed checkout
regardless of where the script was invoked from. Any checkout at a
different depth (a git worktree, a standalone clone) couldn't run the
suite, and even a resolved root would still test the wrong code.
Walk up from the script location to find the docker root (bin/clinotty +
src/), derive the module's path relative to that root, and invoke phpunit
via its absolute in-container path so a differing checkout depth no
longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
canonical path when the checkout isn't under src/.
tests/bootstrap.php had the same fixed-depth assumption and failed with
an unhelpful "Failed opening required ..." error. Walk up for
vendor/autoload.php instead, throw a RuntimeException naming the
searched path when not found, and derive the Logger/Image/minify
autoload map entries from the resolved root.
Fixes #111 f60df9 -
fix(test): resolve module and Magento root by walking up, not fixed depth
bin/test assumed the module sits exactly 5 levels below the docker root
and always tested the canonical vendor/swissup/module-pagespeed checkout
regardless of where the script was invoked from. Any checkout at a
different depth (a git worktree, a standalone clone) couldn't run the
suite, and even a resolved root would still test the wrong code.
Walk up from the script location to find the docker root (bin/clinotty +
src/), derive the module's path relative to that root, and invoke phpunit
via its absolute in-container path so a differing checkout depth no
longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
canonical path when the checkout isn't under src/.
tests/bootstrap.php had the same fixed-depth assumption and failed with
an unhelpful "Failed opening required ..." error. Walk up for
vendor/autoload.php instead, throw a RuntimeException naming the
searched path when not found, and derive the Logger/Image/minify
autoload map entries from the resolved root.
Fixes #111 c8861a -
fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack (#110)
fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
into two scripts specifically so the assignment stays synchronous. But that split runs before the main
processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
rewrites it to the deferred type exactly like the pre-split single script was, leaving
window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.
- shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
write) and skips retyping/moving it.
- fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
the split entirely.
Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
document, one reproducing the bug when it doesn't).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> 28291b -
fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack
fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
into two scripts specifically so the assignment stays synchronous. But that split runs before the main
processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
rewrites it to the deferred type exactly like the pre-split single script was, leaving
window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.
- shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
write) and skips retyping/moving it.
- fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
the split entirely.
Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
document, one reproducing the bug when it doesn't).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8ad474 -
Merge pull request #109 from swissup/claude/inspiring-elbakyan-cfe2e6
fix(dom): keep the page's own <meta charset> during optimization 3f00d8 -
fix(dom): keep the page's own <meta charset> during optimization
TrackedDomDocument injects <meta http-equiv="Content-Type" content="text/html;
charset=utf-8"> before loadHTML() so libxml interprets bytes as UTF-8, then
strips it again in its saveHTML() override.
addUtf8Charset() skips the injection when the page already declares a charset,
but removeUtf8Charset() stripped unconditionally, and stripMetaCharset()'s last
pattern matches *any* meta carrying charset=utf-8. On a page that declares its
own charset nothing was injected, yet the page's own declaration was deleted.
Magento\Framework\View\Page\Config\Renderer renders <meta charset="%content"/>
from the `charset` metadata on every page, so every optimized page lost its
charset declaration.
Not a libxml artifact and not the patch/strategy layer: libxml round-trips the
tag intact, the DOM node is never detached, and the loss reproduces on a bare
parse with zero patches recorded. It happened purely in the string
post-processing of saveHTML(), which is why the <head> opening tag stays
byte-identical.
Impact is usually masked because PHP appends charset to the Content-Type header
when default_charset is set. With default_charset empty the header carries no
charset and the meta is the only encoding signal, leaving the browser to guess
from its locale. Lighthouse also reports "Charset declaration is missing or
occurs too late", which a page-speed module should not be causing.
Fix: record what addUtf8Charset() actually inserted ('none', 'meta', 'head' or
'wrapper') and only strip when we injected something. Fragment-mode structure
restoration still runs, so the injected helper tag is still cleaned up.
Regression coverage in CharsetMetaPreservedTest pins the behaviour and the
cause: the charset survives DeferJs on the minisport production fixture, stays
the first meta in <head> and inside the first 1024 bytes, a non-UTF-8
declaration is never rewritten, the injected helper tag still never leaks, and
two guards assert that libxml preserves the tag and that the loss is
patch-independent so no replacement strategy can be blamed later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1951 -
Version 1.18.5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b3dcbb -
Merge pull request #108 from swissup/test/production-head-order-27428
test(defer): cover the stock-RequireJS production head 479d3d -
test(defer): cover the stock-RequireJS production head
Every real-page fixture in this suite is a Breeze page, and Breeze never renders
Magento's RequireJS bootstrap. Nothing covered the configuration the TTF-27428
failures live in, which is how 1.18.2 and 1.18.3 both shipped with a broken
bootstrap order.
Rather than hand-write the head, derive the asset order from the code that
produces it in production: Magento\RequireJs\Block\Html\Head\Config and our own
Block\Html\Head\Scripts, both run against a real asset Collection with
minification reported as enabled and JS bundling on. A developer-mode install
cannot produce this order at all -- Minification::isEnabled() short-circuits on
the app mode, so requirejs-min-resolver.js is never even rendered.
The resulting order matches what the affected store serves, and running the
released builds through it reproduces each shipped regression:
1.18.2 the global `var require = {baseUrl}` block and
requirejs-min-resolver.js are left behind while require.js, mixins and
requirejs-config stay in <head>. The global object then overwrites an
already-initialised window.require, which is the reported
"require.config is not a function", and the .min suffix is never
applied, which is the reported mage/requirejs/static.js 404.
5 of 7 tests fail.
1.18.3 our bundle pool and override.js are relocated and made async.
3 of 7 tests fail.
1.18.4 passes.
Refs TTF-27428
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d2c33 -
Merge pull request #105 from swissup/fix/fuzzy-content-strategy-head-corruption
fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy (#104) ac1fae -
fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy
Two bugs in apply() caused a last-resort patch to replace a <meta>
tag in <head> with a <body> element (e.g. <img>) when the patched
node's attribute value (alt, src, class ≥ 10 chars) appeared earlier
inside a head meta description's content="..." attribute.
Fixes #104 (radiatorkopen.nl: meta description overwritten by <img>).
Changes:
- Restrict strpos search to the <body> substring; <head> content is
never a valid match target for body-element patches.
- After expanding to the surrounding element, reject the match if the
element tag name differs from the patched node's tag name
(<meta> ≠ <img> → continue to next candidate).
Tests:
- testDoesNotReplaceHeadMetaWhenAltTextAppearsInMetaDescriptionFirst
reproduces the exact radiatorkopen.nl scenario (was failing).
- testReturnsNullWhenOnlyMatchIsInsideHead covers the variant where
the only document match is inside <head> (must return null). 772112 -
Version 1.18.4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> aa4b80 -
Merge pull request #102 from swissup/fix/requirejs-bundle-pool-order-27428
fix(defer): keep the bundle pool and our RequireJS patches in place 7aeb97 -
test: repair the typo'd DOCTYPE in the offset fixture
`<!` followed by a space is not a doctype, so libxml parsed it as a bogus
comment. With LIBXML_HTML_NOIMPLIED the parser then had no document element to
attach the real <html> to, emitting "misplaced <html> tag"; Magento's unit
bootstrap promotes that warning to an exception, so the test errored on every
run instead of asserting anything.
Also close the space inside the require.js src in the same fixture. That one did
not fail the test — the assertions only count //script[@src] and compare their
offsets — but the path was meaningless as written.
The three other fixtures in this file already spell the doctype correctly.
Fixes #103
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> cdbf37 -
fix(defer): narrow the RequireJS patch guard to override.js
The bootstrap guard matched the whole Swissup_Pagespeed/js/lib/requirejs/
directory, which pinned preload-all-scripts.js in <head> as well. That file is
not bootstrap: addPreloadAllScripts() add()s it at the end of the asset
collection rather than inserting it after the min resolver, and its entire body
runs inside a window load handler, so it cannot race require.config(). Guarding
it only costs a render-blocking head script on stores with Force RequireJS
Loading enabled.
Match 'Swissup_Pagespeed/js/lib/requirejs/override' instead, and correct the
comment: it described insert()-after-min-resolver placement that applies to
override.js alone.
testPreloadAllScriptsStaysOptimizable pins the reverse direction - the file must
still move to body end and take async past the counter threshold. It fails with
the directory-wide prefix and passes with the narrowed one.
Also assert asset presence while collecting positions in
testLiveHeadKeepsBootstrapChainInOrder: strpos returns false for a dropped
asset, which asort() sorts first, reporting a bogus ordering violation instead
of the missing script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e0e10 -
fix(defer): keep the bundle pool and our RequireJS patches in place
The 1.18.2 bootstrap guard was meant to cover the bundled module pool, but
'js/bundle/bundle' only matches Magento's own bundle0.js. Advanced JS Bundling
names its pool after the layout handle -- default-bundle0.js,
cms-page-view-bundle0.js -- so none of our bundles ever matched, and
Swissup_Pagespeed/js/lib/requirejs/override.js was not listed at all.
Combined with the 1.18.2 counter change, where skipped-but-executable scripts
consume a slot, these scripts crossed the jsCounter > 5 threshold and started
getting async -- something that did not happen before 1.18.2. Both call
require.config(): the bundle pool registers the jsbuild sources, and override.js
wraps require.config() and must be installed before requirejs-config calls it.
So on a store running Advanced JS Bundling or Interactive Defer, 1.18.3
introduced a fresh instance of the very failure class it fixed.
Match the whole js/bundle/ directory instead of a filename prefix (anything
living there is a module pool by construction) and add our own RequireJS patch
directory.
Regression coverage uses the head sequence dev2.dlastore.com actually serves,
with both features on: six ignored third-party inline blocks, the global require
config, then Magento's bootstrap interleaved with the bundle pool. The fixture
asserts the whole chain stays ahead of the body content, carries no async/defer,
and keeps its document order. It fails in seven places without the fix.
Refs TTF-27428
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1b477c -
Version 1.18.3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 82a806 -
Merge pull request #101 from swissup/fix/requirejs-bootstrap-coverage
fix(defer): cover every RequireJS bootstrap asset and stop counter drift 5a4389 -
fix(defer): count only executable scripts and cover global require config
Three follow-ups on the previous commit in this branch.
Counting every skipped script restored bootstrap coverage but also counted
inert blocks. text/x-magento-template, text/html and JSON payloads are never
executed by the browser, and Magento emits a handful of them on most pages
(minicart, checkout, swatches). Counting them inflated jsCounter and pushed
later scripts past the async (> 5) and third-party defer (> 1) thresholds, so
scripts that used to stay synchronous started getting async and losing their
execution order. jsCounter now advances only for script tags the browser
actually runs: an empty type, a JS MIME type, or the configured delayed type
(those are executed by the delayed-script runtime, so they keep their slot).
The inline bootstrap matcher only recognized the require.config() call. The
other legitimate form, a global `var require = {...}` / `window.require =
{...}` object emitted before require.js loads, carries the same
baseUrl/paths/map data and is under the same ordering constraint, so it was
still relocated behind the content and reproduced TTF-27428 on themes that
use it. Added REQUIREJS_INLINE_GLOBAL_PATTERN.
Inline matching is textual, not parsed, so a script mentioning the config
statement inside a string or a comment is opted out too. That direction is
deliberate - a missed optimization instead of a broken RequireJS bootstrap -
but it was documented as if only src matching had that property. Corrected the
docblock and pinned the behaviour with a test. Also gated both patterns behind
a stripos() check, since the matcher runs on every inline body including the
multi-hundred-kilobyte checkoutConfig payload.
Tests: 35 -> 42. Verified against the pre-fix code: 6 failures, one per gap
(inert-counter case plus five global-config spellings). With this change
705/705 green, phpcs 0 errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ee4c79 -
fix(defer): cover every RequireJS bootstrap asset and stop counter drift
PR #99 protected only requirejs/require.js, mage/requirejs/mixins.js and
requirejs-config.js. Magento renders up to eight bootstrap assets in <head>
(Magento\RequireJs\Block\Html\Head\Config::_prepareLayout), and the rest were
still relocated to the end of <body>:
- requirejs-min-resolver.js patches ctx.nameToUrl to append the .min sign and
is emitted whenever JS minification is on, i.e. in production
- mage/requirejs/baseUrlResolver.js + requirejs-map.js install the URL map
- mage/requirejs/static.js and js/bundle/bundle*.js define the module pool
Moving any of them behind the content reproduces the same failure class as
TTF-27428: module ids resolve against the bare baseUrl and static URLs are wrong.
Replace the five ignore signatures with isRequireJsBootstrap(), which matches
external assets on src only (an unrelated script merely mentioning a bootstrap
path in its code is no longer opted out of deferring) and inline blocks on the
require.config()/requirejs.config() call itself, tolerant to any spacing.
Also increment jsCounter for skipped scripts. Without it every ignore signature
shifted the unpack/async/defer thresholds of later scripts: with PR #99 only
three of six eligible inline scripts were still delayed on a page whose <head>
carries the bootstrap.
Extend RequireJsBootstrapOrderRegressionTest with a provider over all eight
minified asset names, seven inline-config spellings, a guard that a script only
mentioning a bootstrap path stays optimized, and a counter-stability assertion.
Against the previous code the new cases produce 9 failures; with this change the
suite is 698/698 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8ae870 -
Merge pull request #100 from swissup/fix/category-image-swap-xss-14036
fix(image): keep the processed marker out of the DOM (#xss-14036) 374f33 -
fix(dom): rank class last in attribute matching (#xss-14036)
Follow-up on review of the processed-marker fix.
AttributeBasedStrategy tried `class` before `src`, which is what let the
production patch land on a foreign product image in the first place. The
ambiguity guard alone does not cover a class that matches exactly one -
but wrong - element, so order the attributes from most to least
identifying and keep `class` as the last resort. Testing for a second
match from the first match's offset replaces the full-document
preg_match_all, and a skipped match is now logged instead of silently
dropping the optimization.
ProcessedNodeRegistryTrait no longer requires the consuming class to call
an initializer from its constructor: the storage is created on first use,
so a reader can never hit an uninitialized typed property. Added __clone
so a cloned document gets its own registry, and documented that entries
are keyed by PHP object identity and do not survive a node being rebuilt
from HTML.
PipelineCategoryImageSwapTest claimed to reproduce the image swap. It
does not: in a fixture that small OffsetBasedStrategy still resolves the
orphaned patch group, so AttributeBasedStrategy is never reached and no
image is swapped. Its src assertions are non-regression guards; what is
actually red before the fix is the dropped Lcp patch and the leaked
marker. Docblock now says so, and the swap stays pinned at unit level.
Tests: 673 tests, 2030 assertions, OK.
phpcs --standard=Magento2 on the touched files: 0 errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e9d02 -
fix(image): keep the processed marker out of the DOM (#xss-14036)
On a category page the first two product images were rendered with each
other's <img> tag. Reproduced on mcprod.boutinot.com/wines.html: item 1
links to CHV722 but shows CHV726, item 2 the reverse. Only the two images
that LCP marks with fetchpriority="high" were affected, and the wrong
output was then stored in the full page cache.
markImageAsProcessed() set data-pagespeed-processed through the raw
\DOMElement::setAttribute(), so the mutation was never recorded as a patch.
That made the oldHtml of every later patch of the same node stale, so
PatchGrouper could not chain it and the patch fell through to the fuzzy
replacement strategies. AttributeBasedStrategy then matched by
class="product-image-photo" - shared by every product image - and rewrote
the first product image with the second product's HTML.
Keep the marker in an SplObjectStorage registry on TrackedDomDocument
instead of in the DOM, and stop AttributeBasedStrategy from matching by a
class that several elements share. A patch that fails to apply only loses
an optimization; a patch applied to the wrong element corrupts the page.
Side effect: data-pagespeed-processed no longer appears in page output.
Nothing in JS, templates or tests reads it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> c5b7a3 -
Version 1.18.2 47a960
-
Merge pull request #99 from swissup/fix/requirejs-bootstrap-load-order
fix(defer): keep RequireJS bootstrap in place so map/paths apply first 9504be -
fix(defer): keep RequireJS bootstrap in place so map/paths apply first
DeferJs moved every <head> script to the end of <body>. That relocated
Magento's RequireJS bootstrap (requirejs/require, mage/requirejs/mixins,
requirejs-config) behind the body content, so early synchronous require([...])
calls executed before requirejs-config applied its map/paths. RequireJS then
resolved module ids against the bare baseUrl, requesting wrong static URLs
(e.g. domReady.min.js instead of requirejs/domReady.min.js). Browser-dependent
parse/execute timing made it surface in Chrome but not Firefox (TTF-27428).
Add the RequireJS bootstrap and inline require.config()/requirejs.config()
blocks to the defer ignore signatures so they are neither moved, deferred, nor
made async — same mechanism already used for NREUM.
Add RequireJsBootstrapOrderRegressionTest guarding the load-order invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> ec4081 -
Version 1.18.1 31e2a3
-
Merge pull request #98 from swissup/fix/directmatch-mbstrpos-perf
Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches f71c7a -
Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches
DirectMatchStrategy::findAndReplace branched to mb_strpos()/mb_substr() for
any non-ASCII oldHtml. On PHP 8.2 mbstring that path is ~O(n*m): a Breeze
enterprise product page produced three merged body-level patches (~310-388KB,
containing "£"), each taking ~8s in DirectMatch (which then failed the match
anyway), accounting for ~24s of the ~29s page-generation time.
Use byte-based strpos()/substr_replace() unconditionally. This is correct for
UTF-8 because UTF-8 is self-synchronising: a valid substring's byte sequence
only matches at a character boundary, and substr_replace() operates on byte
offsets. Also removes the now-dead charPosToBytePos() helper.
Verified on staging: page generation 32s -> 6.6s. Full module test suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 779231 -
fix(preload): default missing as attribute to style on existing links
In-place update (1.18.1) stopped writing the "as" attribute that the
old re-create path always set. rel="preload" without a valid "as"
triggers browser warnings and may double-fetch the resource.
- add regression test for the missing-as default
- rename testProcessRemovesDuplicatePreloadLinks to match the
keep-in-place semantics; drop stale comments
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 47d617 -
fix(preload): keep existing preload links in place to preserve CSS order
AddLinkPreload removed every existing rel="preload" link and re-inserted
it right after <title>, above the critical <style>. Deferred stylesheets
(converted by DeferCss, with onload flipping them back to
rel="stylesheet") ended up before critical CSS in the DOM, inverting the
cascade: critical CSS overrode the full theme CSS after load.
- Existing preload links keep their DOM position; per-type rel,
fetchpriority (within limits) and crossorigin are updated in place
- Only new preloader assets are inserted after <title>; href dedupe
against existing links and the live-script prefetch guard are kept
- Replace applyPreloadChanges() with updatePreloadLinkInPlace() +
insertPreloadLinks()
Tests: add regression tests for link position relative to critical
<style>; suite OK (644 tests, 1944 assertions).
Fixes #97
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 07ef75 -
fix: replace deprecated SplObjectStorage::contains() for PHP 8.5
PHP 8.5 deprecates SplObjectStorage::contains(); with an error handler
that escalates deprecations (e.g. Ignition) this kills page rendering
inside the optimizer pipeline. Use offsetExists() — identical
semantics, available since PHP 5.3.
Fixes #96
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> cee3e2
swissup/module-pro-labels — 1.10.1 (was 1.10.0)
-
Version 1.10.1 c44745
-
Add null check for associated products in getCheapestFromGrouped method 9156db
swissup/module-quantity-switcher — 1.2.4 (was 1.2.2)
-
Version 1.2.4 e4c020
-
Merge pull request #5 from swissup/fix/arrow-room-lost-after-mount
fix(arrows): keep the arrow room after the switcher mounts 1a9480 -
fix(arrows): keep the arrow room after the switcher mounts
`.control:has(> .input-text.qty)` only matches the server-rendered markup.
`product.js` wraps the input into `.qty-wrapper` on init, after which the input
is no longer a direct child of `.control`, the rule stops matching and the 20px
reserved on each side for the arrows disappears — the arrows overflow the field
and the layout shifts once JS runs.
Match the input as a descendant instead, which holds both before and after the
wrap. Verified in the browser: `.control` padding went `0px 20px` -> `0px` the
moment the wrapper was inserted, and stays `0px 20px` with this selector.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 082597 -
Version 1.2.3 f2d44f
-
Fixed MSI error for bundle products (with no MSI source) on the cart page aa1c46
swissup/module-rich-snippets — 1.8.5 (was 1.8.4)
-
Version 1.8.5 2685fe
-
Refactor Offers class to use PriceFormatter for price formatting (#43) 830f60
-
Enhance AdditionalProperty handling by adding 'id' field and include it in structured data as 'propertyID' (#43) c05490
swissup/module-seo-cross-links — 1.2.0 (was 1.1.26)
-
Version 1.2.0 680e1b
-
To previous commit. e9e9f3
-
Fix for blocks content.
Search in cms content to filter blocks. 5f2177 -
Fix php stan. 4d26b3
-
Support CMS block. e097d4
swissup/module-testimonials — 1.4.2 (was 1.4.0)
-
Version 1.4.2 4c12e4
-
Fixed wrong output when rating is not set 64ce08
-
Added show image config and more options for visible slides dropdown 283b05
-
Version 1.4.1 8ca632
-
Slider widget: added arrows/dots configuration 68ea17
swissup/theme-frontend-argentobreeze-force — 3.0.1 (was 3.0.0)
-
Version 3.0.1 924b64
-
H1-H3 and block title font sizes https://github.com/swissup/argento-breeze/issues/3 573e43
swissup/theme-frontend-argentobreeze-stripes — 3.1.3 (was 3.1.0)
-
Version 3.1.3 99a8af
-
H1-H3 and block title font sizes https://github.com/swissup/argento-breeze/issues/3 7bf6d1
-
Version 3.1.2 5e6042
-
Fixed gap in Highlight blocks headers in BCB homepage content 3b512e
-
Added Product Listing Item Background config to Theme Editor 12578b
-
Version 3.1.1 f241e3
-
Updated footer CMS content to use Theme Editor colors 63952b
swissup/module-core — 1.13.1 (was 1.12.27)
-
Version 1.13.1 5b7b24
-
Fixed invisible update instructions when Mollie Payments is installed 72aab6
-
Version 1.13.0 f34c7b
-
Fill the links after ajax refresh b6a316
-
Show loading state on the "Check for Updates" button 1ab16e
-
Use optimized repo to fetch latest releases 1aa8fb
-
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a -
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2 -
Mark core section with badge when outdated modules found 9a3764
-
Outdated modules badge and Modules config group (#25) 7df026
-
MCS fix 3b9121
-
Update instructions 0e686f
-
Read links info from marketplace meta 21e95b
-
Keep the last check time when a refresh fails
Removing the stored time was what made the next load re-check the feed.
An unreachable feed left no time at all: the config page had nothing to
display, and every page load kept re-checking until one request made it
through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9 -
Post the modules refresh to an action of its own
The refresh drops the version check throttle and re-reads the remote
source, and a get carries no form key validation - Magento checks it for
post requests only, and the url secret key is optional. Reading the list
stays a get, so the two live in separate actions now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9 -
Keep the module links out of the response
The links cell is rendered by the server and never touched by js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db -
Send only the module metadata the browser renders
The merged Loader record carries the local filesystem path and the
download, license and purchase-code fields of the remote feed. None of
them is rendered by the config page, so keep them on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425 -
Too much red 2e307e
-
Use the same `outdated` class name everywhere
The version span was already renamed, while the row and the js still said
`_outdated` - a row that stopped being outdated kept its red version pill.
Since the row now wears the very same class, the pill rule had to be
scoped to the version cell, or the whole row would turn red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086 -
Report the check time with the modules, and keep outdated rows first
Two things the clients were guessing at:
The `Check for Updates` handler stamped the label with the current time,
but the request may have re-used the stored packages after a failed feed
request - `just now` was not always true. And an automatic load did check
the remote source, yet nothing updated the label at all. The endpoint now
returns `['items' => ..., 'last_check' => ...]`, and the label follows the
time the server reports for every load.
The table was re-rendered cell by cell, so a module that became outdated
kept its old position while the counter above it grew. Mark the rows and
move the outdated ones back to the top - both groups stay sorted by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271 -
Keep the last check time once the check is due
The timestamp was stored with a one hour lifetime and the throttle was
`is the entry still there?`. Handy, but it also meant the config page
could no longer tell how old the data was as soon as the check was due -
exactly when that is worth showing next to `Check for Updates`.
Store it without a lifetime and compare the age instead. Same hour
boundary, and the label now reads `Last checked 2 h ago` instead of
disappearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a -
Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66
-
Hovered color for outdated badge 0a0016
-
Show outdated modules count in config and add Modules group
The admin had no hint that installed Swissup modules have updates
available - one had to open Module Manager to find out.
Add a counter badge to the Swissup tab of the config navigation, and a
Modules group in Swissup > Core listing every installed module with its
installed and latest version, outdated ones first.
Neither of them may slow down the config page, so both render from the
data stored in var/swissup/core (Loader::setOfflineMode()), and the
versions, the counters and the badge itself are then refreshed by a
single ajax request shared by all the consumers. `Check for Updates`
re-runs that request with refresh=1, which drops the version check
throttle and re-reads the remote source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6 -
Move core config to the top as it will display outdated modules 115f37
-
Added ability to immidiately re-check remote source for updates `-r` 357d33
-
Keep the remote packages data in files instead of the cache (#24) cbeecc
-
Trim the comments down to what is not in the code
The two lock docblocks carried the same paragraph, and save() explained
the reasoning that belongs in the commit that introduced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9 -
Serialize the writes with a lock taken before the entry is opened
openFile() truncates whatever it opens, so a lock taken afterwards keeps
the writes apart but not the truncations. Two saves could interleave into
one file: the second one truncates, the first one writes a whole entry,
and the second one then overwrites its beginning - leaving the head of
one entry on the tail of another. The length line does not catch that
when both entries encode to the same size, as fixed width values like a
version hash always do.
Take the lock before opening, on a file of its own - the entry cannot be
opened before the lock is held, and the file lock() uses is likely held
by the caller already, which flock would deadlock against.
Failing to open a lock file is no longer reported as a lock held by
somebody else. A lock left behind by another user cannot be opened at
all, and reporting that as contention kept the caller away from the
entry for good - silently serving a list that could never be refreshed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28 -
Don't download the list that another process has just stored
The version is fetched before the download lock is taken, so a process
that was still asking for it when the lock was released would go on to
download the very list the lock holder had just stored. Look at the
storage once more before downloading.
Also correct what the save() lock claims to do: it serializes the
writes, but the truncation happens in openFile(), outside of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0 -
Reject an entry whose expiration time is not a number
A corrupt header such as `expires:invalid` was cast to zero, which reads
as "never expires" - so an entry that should have gone stale could be
served indefinitely instead of being treated as missing. Validate the
field the same way the length line is validated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16 -
Keep the remote packages data in files instead of the cache
The packages list was kept in the Magento cache, so every cache flush
threw it away and the next admin request had to download it again. It is
now stored under var/swissup/core, where it outlives the flush.
FileStorage writes the expiration time and the data length ahead of the
contents, so an entry knows itself when it goes stale, and a reader can
tell a half-written entry from a complete one - openFile() truncates the
file before the write lock is taken, so a reader can catch the entry
mid-save. An incomplete entry is reported as missing and refetched,
rather than served as if it were whole.
Remote revalidates against the version hash from packages.json, at most
once an hour, and only downloads the full list when that hash moves. The
download itself is guarded by a non-blocking lock, so several admins
hitting the page at once produce one request instead of one each - the
others serve the stored copy, or wait for the download when there is
nothing stored yet. fetch() now reports connection errors and 4xx/5xx
responses by returning an empty body, letting the caller fall back to
the stored data instead of decoding a failure page.
The admin notification feed keeps its last-update timestamp in the same
storage, replacing its own copy of the file handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd -
Small fixes f7eef6
-
Single source of truth for is_outdated, is_installed 37ad01
-
Fixed `Undefined array key "type"` warning b2a9c1
-
Don't show non installed modules when asking for outdated bf5fb1
swissup/module-breeze-content-builder — 1.2.0 (was 1.0.6)
-
Version 1.2.0 7f774f
-
Changes for support of the contact us page 812853
-
Added h1 for the Text component and switched to tags instead of CSS classes e054e6
-
Added new Map component c2d71b
-
Improve Columns vertical stretch 6b5da8
-
Version 1.1.0 d0ef07
-
Merge pull request #35 from breezefront/feat/ai-components-bridge
feat(content-builder): apply AI-generated components to the working copy (#34) fa5587 -
Added new Video component 94bb8d
-
fix(content-builder): clone the incoming array before installing it as the working copy d02528
-
fix(content-builder): stay a no-op until sub-modules finish initializing 2c2289
-
fix(content-builder): commit a pending dirty edit before pushing the AI snapshot 3a1ce7
-
feat(content-builder): apply AI-generated components to the working copy (#34) 030d4c
-
Added 5 columns layout for the Columns component 4adc04
-
Version 1.0.12 ccc005
-
fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)
Three review findings on the H1 work:
1. The guard counted only Banner components, so it stayed silent on the most
common duplicate: a CMS page with a Content Heading, where
Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
module-theme's html/title.phtml emits <h1 class="page-title">. Load now
reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
same flag from its opts so BLB can declare the product/category page title,
and the guard adds that H1 to the count.
2. The guard only ran on a headline_type change, so pasting an H1 Banner never
warned. It now runs after paste and after content load as well, which also
covers a tree that arrived from an AI build.
3. getDraft() reloaded the draft by id without checking the load succeeded. If
the row was deleted between the collection query and the reload, it returned
an empty model and the caller saved a row with no page_id — rejected by
MySQL, since page_id is NOT NULL with no default. It now falls back to the
seeded-draft path, which is extracted into createSeededDraft().
Load's constructor gained a dependency, so setup:di:compile is needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> abc266 -
feat(banner): allow H1 for the banner headline (#32)
The Banner headline_type prop only offered h2-h6, so a page built entirely
in Content Builder shipped without a primary heading — the document had no
h1 at all, which is an on-page SEO defect.
Add h1 to the option list and to the template's tag whitelist. The default
stays h2, so existing pages and newly inserted Banners render exactly as
before.
Because a page should carry at most one h1, the editor now shows a soft
notice when a headline is switched to h1 while another Banner already uses
it. It warns rather than blocks — a legitimate layout may keep its h1
outside the Content Builder bands.
The Text component's heading_type is deliberately left alone: it renders a
div with a heading class, not a real heading tag, so an "h1" there would be
visual only and misleading.
Fixes #32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ced844 -
fix(content): reload draft through singular load so updated_at advances
getDraft() returned a collection-hydrated item with empty storedData,
so save()'s update-diff wrote every column back unchanged, pinning
updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.
Related to #31 (does not close it — content-loss symptom unconfirmed). b1759b -
fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)
Three review findings on the H1 work:
1. The guard counted only Banner components, so it stayed silent on the most
common duplicate: a CMS page with a Content Heading, where
Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
module-theme's html/title.phtml emits <h1 class="page-title">. Load now
reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
same flag from its opts so BLB can declare the product/category page title,
and the guard adds that H1 to the count.
2. The guard only ran on a headline_type change, so pasting an H1 Banner never
warned. It now runs after paste and after content load as well, which also
covers a tree that arrived from an AI build.
3. getDraft() reloaded the draft by id without checking the load succeeded. If
the row was deleted between the collection query and the reload, it returned
an empty model and the caller saved a row with no page_id — rejected by
MySQL, since page_id is NOT NULL with no default. It now falls back to the
seeded-draft path, which is extracted into createSeededDraft().
Load's constructor gained a dependency, so setup:di:compile is needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6d73f -
feat(banner): allow H1 for the banner headline (#32)
The Banner headline_type prop only offered h2-h6, so a page built entirely
in Content Builder shipped without a primary heading — the document had no
h1 at all, which is an on-page SEO defect.
Add h1 to the option list and to the template's tag whitelist. The default
stays h2, so existing pages and newly inserted Banners render exactly as
before.
Because a page should carry at most one h1, the editor now shows a soft
notice when a headline is switched to h1 while another Banner already uses
it. It warns rather than blocks — a legitimate layout may keep its h1
outside the Content Builder bands.
The Text component's heading_type is deliberately left alone: it renders a
div with a heading class, not a real heading tag, so an "h1" there would be
visual only and misleading.
Fixes #32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 620e7f -
fix(content): reload draft through singular load so updated_at advances
getDraft() returned a collection-hydrated item with empty storedData,
so save()'s update-diff wrote every column back unchanged, pinning
updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.
Related to #31 (does not close it — content-loss symptom unconfirmed). 5c797e -
Remove custom accordion script d8cb73
-
Added new Accordion/FAQ component 3135bd
-
Version 1.0.11 17c28e
-
Use text color for Heading when set in the Text component 0335a1
-
Added font-size field and source button to WYSIWYG editor dfc235
-
Improve grid component styles on 640px-768px screen sizes fe4e91
-
Added Feature Left/Right layouts to Grid component b1b27c
-
Use banner text color for headings inside banner if set eb8b24
-
Version 1.0.10 86c7b3
-
Improved drag-and-drop in the components tree, closes #28 840287
-
Version 1.0.9 033a62
-
Added AI-readable descriptions to component definitions
Each component now carries a <description> element instead of an XML
comment above its declaration, so the text is available to code and not
just to whoever opens the file. ComponentXmlReader exposes it as the
"description" key of every definition.
The wording is aimed at a consumer that has to pick a component without
seeing the storefront: what the component is for, which component to
prefer when two of them overlap, how many children slots a container
takes, and which props must be left empty because their values are
entity IDs or widget directives that cannot be guessed.
First consumer is the AI landing page builder, which turns the registry
into the component catalog it sends to the model.
Refs breezefront/module-breeze-ai#23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bad797 -
Added AI-readable descriptions to component definitions
Each component now carries a <description> element instead of an XML
comment above its declaration, so the text is available to code and not
just to whoever opens the file. ComponentXmlReader exposes it as the
"description" key of every definition.
The wording is aimed at a consumer that has to pick a component without
seeing the storefront: what the component is for, which component to
prefer when two of them overlap, how many children slots a container
takes, and which props must be left empty because their values are
entity IDs or widget directives that cannot be guessed.
First consumer is the AI landing page builder, which turns the registry
into the component catalog it sends to the model.
Refs breezefront/module-breeze-ai#23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9d3287 -
Version 1.0.8 ea7d14
-
Force STRAIGHT_JOIN usage to fix slow query on old MariaDB version 10.6 01441a
-
Do not use rand in fallback product provider 9270bb
-
Product grid performance improvements 0804ac
-
Performance improvements for random product lists d5cab2
-
Version 1.0.7 39992b
-
Fixed less error on old compiler 9b5330
-
swissup / argento-m2
1.54.0 Aug-
1.54.0
Report (rev.1)
Package was generated and uploaded to GitHub on with the following dependencies:
swissup/module-ajaxpro:1.7.39
swissup/module-ajaxsearch:1.14.11
swissup/module-amp:1.6.0
swissup/module-askit:1.14.27
swissup/module-attributepages:1.8.6
swissup/module-chat-gpt-assistant:1.1.4
swissup/module-compare:1.2.8
swissup/module-easy-slide:1.8.18
swissup/module-easybanner:1.9.24
swissup/module-easycatalogimg:1.5.15
swissup/module-easytabs:1.13.4
swissup/module-fblike:1.3.9
swissup/module-featured-attributes:1.1.9
swissup/module-font-awesome:1.4.5
swissup/module-gdpr:1.9.2
swissup/module-gdpr-askit:1.1.3
swissup/module-gdpr-reviewreminder:1.0.0
swissup/module-gdpr-testimonials:1.1.1
swissup/module-highlight:1.11.9
swissup/module-hover-gallery:1.3.15
swissup/module-hreflang:1.6.12
swissup/module-hreflang-import-export:1.0.1
swissup/module-lightboxpro:1.3.10
swissup/module-navigationpro:1.19.3
swissup/module-pagespeed:1.19.0
swissup/module-pro-labels:1.10.1
swissup/module-pro-labels-configurable-product:1.0.9
swissup/module-quantity-switcher:1.2.4
swissup/module-reviewreminder:1.1.19
swissup/module-rich-snippets:1.8.5
swissup/module-seo-canonical:1.11.10
swissup/module-seo-core:1.3.10
swissup/module-seo-cross-links:1.2.0
swissup/module-seo-html-sitemap:1.1.23
swissup/module-seo-images:1.3.21
swissup/module-seo-pager:1.4.0
swissup/module-seo-templates:1.7.12
swissup/module-seo-urls:1.5.55
swissup/module-seo-xml-sitemap:1.2.0
swissup/module-sold-together:1.11.4
swissup/module-sold-together-custom-options:1.0.2
swissup/module-sold-together-email:1.0.0
swissup/module-sold-together-import-export:1.0.2
swissup/module-testimonials:1.4.2
swissup/module-theme-editor-argento-chic:1.7.4
swissup/module-theme-editor-argento-essence:1.7.3
swissup/module-theme-editor-argento-flat:1.7.3
swissup/module-theme-editor-argento-force:1.4.2
swissup/module-theme-editor-argento-home:1.2.3
swissup/module-theme-editor-argento-luxury:1.7.2
swissup/module-theme-editor-argento-mall:1.7.3
swissup/module-theme-editor-argento-marketplace:1.7.3
swissup/module-theme-editor-argento-pure2:1.6.4
swissup/module-theme-editor-argento-stripes:1.7.5
swissup/theme-frontend-argento-blank:1.49.5
swissup/theme-frontend-argento-chic:1.48.3
swissup/theme-frontend-argento-essence:1.46.4
swissup/theme-frontend-argento-flat:1.46.6
swissup/theme-frontend-argento-force:1.48.3
swissup/theme-frontend-argento-home:1.48.5
swissup/theme-frontend-argento-luxury:1.48.2
swissup/theme-frontend-argento-mall:1.48.2
swissup/theme-frontend-argento-marketplace:1.48.4
swissup/theme-frontend-argento-pure2:1.46.6
swissup/theme-frontend-argento-stripes:1.48.3
swissup/module-core:1.13.1
swissup/module-suggestpage:1.2.14
swissup/module-image-optimizer:1.0.12
swissup/module-theme-editor:1.12.0
Changelog (autogenerated)
swissup/module-ajaxpro — 1.7.39 (was 1.7.34)
-
Version 1.7.39 650093
-
fix: guard productInfoResolver call in ajaxSubmit (#57 follow-up)
Found while verifying the YQE-14065 fix on the customer's dev store:
their theme (Webspaceconfig/Webspace) ships its own outdated copy of
Magento_Catalog/js/catalog-add-to-cart.js that predates Magento core
adding productInfoResolver support. Our widget extends that file as
its base ($.mage.catalogAddToCart) and called
self.options.productInfoResolver(form) unconditionally, so on that
theme every ajax add-to-cart submit threw
"productInfoResolver is not a function" before the request was even
sent — configurable/size options couldn't be added to cart from
category listing pages.
Root cause is the theme's stale file, not this module — reported
separately. This guard just keeps ajaxpro working regardless of
whether the base widget a theme provides happens to support this
option.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 320f71 -
Version 1.7.38 3b2284
-
Merge pull request #58 from swissup/fix/gift-card-options-data-post-57
fix: stop stripping data-post so third-party widgets can still read it (#57) eb4490 -
fix: pass bind:false when reusing ajaxcian-data-post as an _ajax() helper (#57)
Copilot review on #58: _confirm()'s replacement click handler runs on
every confirmed compare remove/clear click and constructs
AjaxproAjaxcianDataPost({}, element) to reuse its _ajax() method. With
the default options ({} means options.bind stays true), _create()
also calls _bind(), adding a brand new click listener on the same
element — and since data-post is no longer stripped (YQE-14065), that
_bind() call succeeds every time instead of bailing out on a missing
attribute like it used to.
None of those listeners are ever removed, so click N fires N ajax
requests: click 1 adds listener #1 (plus its own direct call), click 2
runs the _confirm handler directly again AND listener #1, click 3 runs
the _confirm handler, listener #1, and listener #2, etc.
Fixed by passing { bind: false } — this call only ever wanted _ajax()
as a helper method. Verified by reverting locally and re-running the
new test: 2nd click produces 3 ajax calls instead of 2 without the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> c5bd0f -
fix: stop propagation in compare.js rebind, close double-submit (#57)
Copilot review on #58: Swissup_Ajaxpro/js/compare's _confirm() rebinds
the click handler on compare remove/clear-all links on every
compare-products section update:
$(selector).off('click').on('click', function (e) { ... });
.off('click') strips ANY click handler already on the element,
including ajaxcian-data-post's own (which stopPropagation()s). The
replacement handler here called _ajax() directly without stopping
propagation of its own. Since ajaxcian-data-post no longer strips
data-post, a click after this rebind reached both this handler's ajax
call and the native document-delegated mage.dataPost handler — a real
double submit for compare remove/clear links specifically. Previously
this was masked because data-post was already gone by the time any
rebind happened.
Added e.stopPropagation() to this handler too, plus a regression test
that reproduces the exact rebind sequence and asserts exactly one ajax
call / zero native-handler calls (and fails without the fix, verified
locally). Needed two additions to the jest $.widget shim along the
way: this._super() support (compare.js's _create calls it) and calling
the widget constructor without `new` (compare.js does
AjaxproAjaxcianDataPost({}, element) directly, as real jQuery UI
widget constructors support).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 727bd4 -
fix: bind click handler synchronously, drop startup delay (#57)
Copilot review on #58: the widget still delayed binding its own click
handler by 500ms. That delay used to be harmless because the old
buggy code removed data-post synchronously in _bind(), so the native
mage.dataPost document-delegated handler couldn't match during the
window either. Now that data-post stays on the element permanently,
a click within that window reached mage.dataPost alone and triggered
a full page navigation instead of Ajaxpro's ajax request.
No commit in this file's history documents a reason for the delay.
Binding synchronously closes the window; our handler is on the
element itself so it still runs (and stopPropagation()s) before the
event can reach the document-level native handler regardless of when
it was bound, as long as it's bound before the click happens.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> cc70c1 -
fix: clone cached data-post before mutating it (#57)
Copilot review on #58: element.data('post') returns jQuery's cached
object for the attribute (same reference on every call). _ajax() was
mutating that object's .data (adding form_key) in place, so a later
$(el).data('post') read — by this widget on a second click, or by a
third party like Mageplaza_GiftCard/js/view/information.js — would see
the leaked form_key instead of the original payload.
Also documents the stopPropagation() trade-off raised in the same
review: it can't be scoped to only the native mage.dataPost handler
(jQuery event namespaces don't affect propagation, and mage/dataPost.js
is a core file we don't patch) — no other ancestor-delegated click
listener touches these links today, so accepted as-is.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 75a2b4 -
fix: stop stripping data-post so third-party widgets can still read it (#57)
ajaxcian-data-post.js renamed data-post to data-post-ajax and removed the
original attribute when hijacking a link for ajax submit. Any other script
reading $(el).data('post') from the same link after that point (e.g.
Mageplaza_GiftCard/js/view/information.js, on the same .tocompare /
.towishlist links used to build Gift Card add-to-wishlist/compare payloads)
got undefined and threw, aborting its own init before it could render the
Gift Card options form.
Keep data-post intact and use stopPropagation() instead to stop the native
mage.dataPost document-delegated click handler from double-submitting.
Verified live on the customer's dev store: Gift Card options render
correctly with the patch, no duplicate ajax request on click.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 508331 -
Breeze: added missing imports 115dd9
-
Version 1.7.37 264578
-
fix: quick view popup silenced by the 1.7.35 gating guard
is-ajaxpro-request's "trust a section load only after a recent ajaxpro
POST" guard (048361e, 1.7.35) only recognized add-to-cart as a plain
GET customer/section/load preceded by a POST. Quick view has no such
POST — it fetches ajaxpro-product directly — so ModalManager never
opened the popup: the request succeeded, the section arrived, nothing
rendered, no error.
Have quick-view declare the request explicitly via the new
isAjaxproRequest.mark() instead of relying on inference.
Reported on 420shop.nl (customer-facing, all listing pages).
Closes #57 30e663 -
fix: ajaxpro-cart/reinit/product section render must not 400 section/load
Magento\Customer\Controller\Section\Load turns any exception thrown by
a section source into an HTTP 400 for the whole request. Cart::getSectionData()
renders the full cart layout server-side even for an empty/absent quote,
and on some environments (Magento 2.4.9 + Breeze, per report) that render
throws, aborting customer/section/load for every guest with an empty cart.
Wrap the render logic in Cart, Init and Product CustomerData classes in
try/catch: on failure, log via the newly injected Psr\Log\LoggerInterface
and return [] for that section instead of letting the exception bubble up.
Core sections (cart, customer, ...) keep loading either way.
Closes #56 5f061c -
docs: add manual QA scenarios ba69b1
-
Version 1.7.36 82272a
-
test: config options and type-source coverage
Covers the options x settings surface at the logic level:
- Helper\Config: master switch (every feature off when
ajaxpro/main/enabled is 0), the override-minicart handle whitelist
(allowed only for minicart / shopping-cart handles, matching the
system.xml depends), standalone flags that ignore the master switch,
and type casts.
- Source\Handle / Source\Type: lock the popup handle and cart type
enumerations referenced by name across system.xml, layout and config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 8f91cf -
test: popup gating matrix across component flavors and popup types
Runs the gating scenarios (popup after add-to-cart, silence on stale
re-fetch, redirect flow) for every combination of component flavor
(luma uiComponent / breeze view) and cartHandle popup type (minicart,
shopping cart, suggest page), plus the simple-popup messages gate and
closeTimeout config variations (disabled, countdown with continue
button, reset on user activity).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ef33d0 -
test: integration coverage of the popup gating chain
Covers section update -> _subscribe -> ModalManager.checkAndShow end
to end: popup after add-to-cart post, popup after redirect-based add
to cart, silence on stale section re-fetch (the bsxl scenario), no
reopen after the popup was shown. This chain is the only protection
on both stacks since disposableCustomerData was dropped (#54).
Refs #54
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 4f7514 -
refactor: drop disposableCustomerData, unify luma and breeze strategy
Luma applied the disposableCustomerData extender to ajaxpro sections
(wiping them from storage and section_data_ids cookie after use) while
Breeze had it commented out — the divergence made stale-section bugs
Breeze-only and the behavior hard to reason about.
Since 048361e the popup opens only within 30s after a real add-to-cart
post (is-ajaxpro-request marker), so a stale re-fetch on page load can
not trigger it on either stack. Ajaxpro sections now persist like any
other customer-data section on both frontends.
Verified on luma storefront: popup on add to cart, section persists,
stale section re-fetch on navigation stays silent.
Closes #54
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> b0a6dd -
refactor: replace PostHelper preference with a before plugin
The preference on Magento\Framework\Data\Helper\PostHelper replaced
the core class entirely and conflicted silently with other modules
customizing the same helper. A before plugin composes instead.
before (not after/around): the decision "caller did not pass uenc
explicitly" is only visible in the original arguments, and no result
re-serialization is needed. Semantics are identical to the old
override: inside an ajax request reuse the real page uenc from the
request parameters.
Old helper class kept as @deprecated for code extending it.
Closes #55
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 4f0ca4 -
chore: strict_types in all php files, drop dead code
- declare(strict_types=1) in every php file (project standard);
verified with php -l, unit tests and storefront smoke run
- remove commented-out debug/dead blocks (md5 debug, unused methods,
disabled section actions)
- drop stale deprecated setup_version from module.xml (no Setup/ dir)
Refs #53
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> a46452 -
test: cover event handling hygiene fixes (issue #52)
Refs #52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> bf5db7 -
fix: event handling hygiene in frontend js
- Namespace click handlers (.off('click.ajaxpro')) in floating cart
and minicart override: bare .off() also removed handlers attached
by other modules.
- Cap modal-manager register() polling at 10s and register anyway:
on pages with permanent ajax activity $.active never reaches 0 and
the interval lived forever.
- Dispose customer-data subscriptions on destroy (ajaxpro.js,
compare.js) to avoid duplicate handlers after component re-init.
- Fix dead fallback typo: form.prop('prop') -> form.prop('action').
Closes #52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 635251 -
test: cover modal auto-close timer (issue #51)
Refs #51
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> e53aaf -
fix: modal auto-close timer throws instead of closing the modal
setTimeout received this.closeModal unbound, so the closeTimeout
feature fired with `this` pointing at window and threw a TypeError.
Same defect in luma and breeze variants.
Closes #51
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> d4fe2b -
test: cover ajax notice message suppression (issue #49)
Adds phpunit setup (mirrors module-breeze-theme-editor) and wires
bin/test unit.
Refs #49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 67257d -
test: cover section reload once-guard (issue #50)
Refs #50
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ceb693 -
fix: infinite section reload loop on website/store mismatch
Minicart and floating cart views reloaded cart sections whenever the
cart section website_id/storeId diverged from window.checkout. When
the quote really belongs to another website/store (same-domain
multi-store), reloading can never fix the mismatch, and since the
Breeze view re-creates on every contentUpdated, the check re-fired
after each reload — an endless cart/ajaxpro-cart request stream.
Reload at most once per page load and guard undefined website_id in
the floating cart (undefined !== websiteId used to trigger it too).
Closes #50
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 84dcbd -
fix: notice messages silently dropped on every ajax request
isMessageToIgnore() compared strpos() result with 1 instead of false,
so it returned true for nearly any message and addNoticeMessage()
discarded all ajax notices — not only the two "choose options"
messages it was meant to suppress.
Closes #49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 79940e -
Version 1.7.35 148d35
-
test: add jest suite for is-ajaxpro-request
Setup mirrors module-breeze-theme-editor: jsdom, AMD transform,
Magento_Customer mocks. Run with bin/test [jest].
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> e6ff4c -
fix: popup opening on page load when ajaxpro sections expire
Any GET customer/section/load with ajaxpro-product in sections was
treated as an add-to-cart request. Expired ajaxpro sections (logged-in
customer, out-of-sync section_data_ids cookie) are re-fetched on every
page load, so the cart popup opened on plain category navigation.
Trust the section load only within 30s after a real ajaxpro POST.
Marker lives in sessionStorage: survives redirect-based add to cart,
per-tab, cleared once the popup is shown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 048361
swissup/module-ajaxsearch — 1.14.11 (was 1.14.6)
-
Version 1.14.11 0db900
-
Fixed layout shift when result list is long 8715cc
-
Fixed overflowed content in Firefox (Apollo theme) de660b
-
Merge pull request #57 from swissup/phpunit12-test-api-56
Migrate Test/Unit off removed PHPUnit APIs (PHPUnit 12 / PHP 8.5) 095093 -
test: migrate removed test APIs for PHPUnit 12 / PHP 8.5
The CI runner targets PHP 8.5, which requires PHPUnit 12.x. Migrate the
unit tests off APIs removed in PHPUnit 10-12:
- setMethods() (removed v10) -> onlyMethods(); all mocked methods verified
to exist on the target class via reflection. Dropped dead duplicate mock
builders (Magento CollectionFactory immediately overwritten by the
Swissup one).
- getMockForAbstractClass() (removed v12) -> createMock().
- will($this->returnValue()/returnSelf()/onConsecutiveCalls()) (removed
v12) -> willReturn()/willReturnSelf()/willReturnOnConsecutiveCalls().
- Non-static data providers (hard error v12) -> public static function.
Also fixed two removed APIs not listed in the issue but caught while
running the suite:
- $this->at() (removed v10) in DataTest and ProductTest -> willReturnMap()
/ atLeastOnce() / consecutive-call stubs.
InitTest was stale independently of PHPUnit: it exercised a removed
getClassNames() method and the old constructor/getSettings signature.
Realigned it to the current Init block and dropped the redundant
getClassNames test (already covered by Helper\DataTest).
Verified: 41 tests, 99 assertions, 0 errors, 0 deprecations on
PHPUnit 10.5 / PHP 8.3 (`bin/test`).
Refs swissup/module-ajaxsearch#56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 20854c -
test: add module-local PHPUnit runner
Add bin/test and phpunit.xml.dist so the unit suite can be run in
isolation via `bin/test` (runs vendor/bin/phpunit inside the container),
mirroring the setup already present in module-askit. .gitignore keeps the
PHPUnit result cache out of the repo.
Refs swissup/module-ajaxsearch#56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> a4e899 -
Version 1.14.10 28072f
-
Breeze: do not require redeployment when changing config d37dfa
-
Version 1.14.9 d8bfef
-
Breeze 2.0 fixes 7c5b2e
-
Revert input-bg for Breeze 2.0 compatibility 88f0a6
-
Version 1.14.8 73c965
-
Magento 2.4.9 fix eaaafd
-
Breeze: use `--input-bg` as input background if set 1e84b3
-
Inherit placeholder color from theme 670f3d
-
Fix for the previous commit 6c4ddd
-
Better fix for unscrollable fullscreen mode 8f7477
-
Improve fullscreen styles 71d92f
-
Fullscreen: sync results width with input 7204a2
-
Improve close button position when outline is visible, fix input height c4bf30
-
Fixed missing close button on fullscreen mode 9aa874
-
Ability to remove styling when ajaxsearch is enabled b7e4ff
-
Breeze: improve dark support f5e2c7
-
Version 1.14.7 453b4d
-
fix: scope autocomplete container to avoid conflicts with multiple instances
Replace global $('.block-swissup-ajaxsearch-results') selectors with instance-
scoped references to prevent conflicts when multiple ajaxsearch widgets exist
on the same page (e.g. header search + nav search).
- breeze/ajaxsearch.js: use this.autoComplete instead of global selector
- ajaxsearch/results.js: addWrappers(), recalcWidth(), _addSectionTitle()
now accept optional $container argument (fallback to global selector)
- ajaxsearch.js: pass scoped $container when calling Results methods 4014dc -
Breeze: compatibility with `header__dark=deep` ec7063
swissup/module-askit — 1.14.27 (was 1.14.21)
-
Version 1.14.27 a71b71
-
Fixed answer(s) Dutch translation d2124a
-
Version 1.14.26 8374b0
-
Merge pull request #74 from swissup/fix/issue-73-i18n-dictionary
Regenerate i18n dictionary, fix uncollectable and stale phrases ef2944 -
Regenerate i18n dictionary, fix uncollectable and stale phrases
`en_US.csv` had drifted well behind the source: 106 rows against the 196
phrases `i18n:collect-phrases` finds in the module. Regenerated it and
brought every locale file back in line with it.
Source fixes that had to come first:
- `listing.phtml` built the answer counter label as
`__($collection->getSize() == 1 ? 'answer' : 'answers')`. The phrase
collector cannot resolve a ternary inside `__()`, so it emitted the
literal `answer' 'answers` as a key — which every locale file then
faithfully translated, while the real `answer` and `answers` strings
stayed untranslatable. Moved the ternary outside the `__()` calls.
- `"id" is incorect` -> `"id" is incorrect` (CreateVote.php).
- `MassAssign.php` used `Assign to...` where the UI component label says
`Assign to ...`, producing two dictionary keys for one label. Unified
on `Assign to ...`.
Dictionary changes:
- `en_US.csv` regenerated from `i18n:collect-phrases`, 106 -> 196 rows.
This also drops the stray quoting on `It is helpful` and `Not much
help`, whose values were ` "It is helpful"` and ` "Not much help"` —
rendered on the frontend complete with the quotes.
- Removed 13 dead keys from every locale (`Hint`, `Askit Items`,
`New Askit Question`, `Answer Information`, `You saved.`, the
`${ $.$data.text }` UI-component leftovers, the misspelled
`We can't find a answer to delete.`, and the mangled
`answer' 'answers`).
- `fa_IR.csv`: removed a duplicate `Not much help` row whose value was
double-quoted, and folded the stray ` answer` key into `answer`.
- Normalised the leading space on the `asks` / `about` / `answer` /
`answers` values — the templates already prepend it.
- All files rewritten fully quoted.
Every locale now parses to exactly two columns per row, carries no
duplicate keys, and contains no key absent from `en_US.csv`.
Translating the newly exposed phrases is left to the translators;
coverage per locale is currently 44-49%.
Refs #73
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0c1b32 -
Merge pull request #72 from swissup/fix/issue-71-locale-csv
Fix malformed "Answer" row in locale files, add cs/sk/hu/ro 8b3f84 -
Fix malformed "Answer" row in locale files, add cs/sk/hu/ro
The "Answer" entry had an unclosed quote in 18 of 19 locale files:
"Answer,Answer
fgetcsv merged that line with the next one, so both "Answer" and
"Entity Type" were lost in every affected locale. Closing the quotes
restores them.
Also:
- add cs_CZ, sk_SK, hu_HU and ro_RO translations
- add the missing "Plain text only. HTML is not allowed.",
"Submit Question" and "Name" keys to en_US, de_DE, it_IT and nl_NL
Translations contributed by @dimdimich in #71. Over-escaped quotes
("""" instead of "") in the submitted hu_HU and ro_RO files were
corrected before merging. fa_IR was already well-formed and is
left untouched.
Fixes #71
Closes #70
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0381cd -
Version 1.14.25 d0112b
-
Merge pull request #69 from swissup/fix/issue-68-null-array-offset
Fix null array offset deprecation on new product form (#68) 1a7529 -
Fix null array offset deprecation on new product form
modifyData() wrote $data[$productId] where $productId is null for a
new (unsaved) product, triggering on PHP 8.5:
Deprecated Functionality: Using null as an array offset is
deprecated, use an empty string instead
Guard against a null product id and return $data unchanged, mirroring
the existing guard in modifyMeta(). current_product_id is meaningless
for a product that does not exist yet, so skipping the write is safe;
editing an existing product is unaffected.
Fixes #68
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 0fd6d0 -
Version 1.14.24 3b54b7
-
test: drop addMethods() for PHPUnit 12 compatibility
PHPUnit 12 removed MockBuilder::addMethods(). CaptchaTest used it to
mock getControllerAction() on the Observer mock, but that is a magic
DataObject getter, not a declared method.
Replace the two mocks with a real Observer created with the data set
(`new Observer(['controller_action' => $controller])`) so the magic
getter returns the controller. Tests pass on both PHPUnit 9.6 (current
CI) and PHPUnit 12.5.
Refs swissup/module-askit#67
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 8412ad -
Fixed php 8.4 error: Cannot unset property $totalCount because it might have hooks in a subclass a679be
-
Use the frontend URL builder directly in blocks (BCB preview fix) 82af94
-
Version 1.14.23 13f6c8
-
fix: mock HttpResponse instead of ResponseInterface in CaptchaTest 106771
-
fix: remove duplicate ResponseInterface use statement in CaptchaTest 6ef280
-
test: update CaptchaTest for injected ResponseInterface
Observer now receives ResponseInterface via constructor instead of
calling controller->getResponse(). Update setUp(), makeObserver(),
and testAjaxBlockWritesJsonErrorToResponse accordingly. ada7aa -
fix: inject ResponseInterface instead of controller->getResponse()
SaveAbstract implements HttpPostActionInterface directly (no AbstractAction),
so its Interceptor has no getResponse(). Use injected ResponseInterface
singleton instead — same object, no dependency on controller type. ff751a -
feat: add Swissup reCAPTCHA protection for Askit question form
- config.xml: enable protect_swissup_askit_new_question_form by default
- system/recaptcha.xml: expose toggle in swissup_recaptcha admin config
Swissup_Recaptcha module uses its own captcha factory plugin to replace
Magento image captcha with Recaptcha model. isRequired() checks
swissup_recaptcha/general/protect_{formId} — this was missing for askit,
causing reCAPTCHA token to render but never be validated (spam bypass). e861b6 -
fix: write JSON error to response before FLAG_NO_DISPATCH for AJAX captcha block 92bd7c
-
test: add unit tests for Observer/Captcha reCAPTCHA and image CAPTCHA blocking logic 67b95c
-
fix: set FLAG_NO_DISPATCH to block controller execution on reCAPTCHA failure for AJAX requests d70979
-
fix: block AJAX form submission when reCAPTCHA validation fails 0739e0
-
Version 1.14.22 2a412a
-
PHP8.5: Using null as an array offset is deprecated, use an empty string 10a2bb
-
PHP8.5: Non-canonical cast (boolean) is deprecated, use the (bool) e14ee6
-
Typo fix c20706
-
Magento 2.4.9 fix 3f838b
-
Magento 2.4.9 fix 9f7426
swissup/module-attributepages — 1.8.6 (was 1.8.1)
-
Added ability to move description to bottom:
```
<referenceBlock name="attributepage.attribute.view">
<arguments>
<argument name="is_description_at_bottom" xsi:type="boolean">true</argument>
</arguments>
</referenceBlock>
``` f46297 -
Version 1.8.5 e137db
-
PHP8.5: Using null as an array offset is deprecated, use an empty string c2a322
-
PHP8.5: Using null as an array offset is deprecated, use an empty string ebee34
-
Version 1.8.4 eb7d54
-
Hide links from Seo html Sitemap based on config 5da82b
-
Version 1.8.3 d53e5b
-
Configurable search field to allow to quickly find the option (#39) eda575
-
Added translation for "No results found" 50c869
-
Configurable search field (disabled by default) daffa6
-
Breeze integration 2f866a
-
Fixed to work nicely with "Group by letter" config ea6c22
-
Transform js into component 901529
-
Add translation d1e420
-
Move styles to css 074dd5
-
Script to data-mage-init 5047aa
-
Attributepages: Added search 78b8df
-
Version 1.8.2 48a150
-
Added styling for mobile view 1f145a
swissup/module-easy-slide — 1.8.18 (was 1.8.16)
-
Version 1.8.18 124a36
-
Fixed JS error on hidden slider, closes #41 5730d8
-
Version 1.8.17 24f2f0
-
PHP8.5: Using null as an array offset is deprecated, use an empty string 1a4514
-
Magento 2.4.9 fix. Tested on 2.4.6 also - these styles not needed. e51c52
swissup/module-easybanner — 1.9.24 (was 1.9.23)
-
Version 1.9.24 ef2490
-
PHP8.5: Using null as an array offset is deprecated, use an empty string 788d7e
swissup/module-easycatalogimg — 1.5.15 (was 1.5.13)
-
Version 1.5.15 a12d86
-
Fixed inability to create new category on PHP 8.5. Closes #17 a5d3fe
-
Version 1.5.14 09fa9c
-
Add ability to wrap block into `block, block-content` 6baa0f
-
No escape added 4540d2
-
Added missing escape 758dbc
-
Ability to add block title and css class using xml layout update
Example:
```
<referenceBlock name="subcategories">
<arguments>
<argument name="block_custom_css" xsi:type="string">block block-hero</argument>
<argument name="block_title_html" xsi:type="string"><![CDATA[Browse <span class="color-muted">categories</span>]]></argument>
</arguments>
</referenceBlock>
``` 7cab67
swissup/module-easytabs — 1.13.4 (was 1.13.2)
-
Version 1.13.4 e8278c
-
Update Category render attribute cccdb6
-
Version 1.13.3 6138b2
-
Add Category render attribute eede1d
swissup/module-font-awesome — 1.4.5 (was 1.4.4)
-
Version 1.4.5 7b323b
-
Add configurable option to disable Font Awesome preload (#7)
* Add configurable toggle to disable Font Awesome font preload
Preload hint is downloaded on every page (~75 KiB) even though most
stores never render an icon using FontAwesome above the fold. New
Swissup > Font Awesome > Preload Font setting (default: Yes, keeps
existing behavior) lets stores turn off the <link rel="preload">
without affecting the CSS or @font-face definition.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Chumak Roman <chumakrom@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 0768b3 -
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 22a42f -
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 04ea9b -
Add configurable toggle to disable Font Awesome font preload
Preload hint is downloaded on every page (~75 KiB) even though most
stores never render an icon using FontAwesome above the fold. New
Swissup > Font Awesome > Preload Font setting (default: Yes, keeps
existing behavior) lets stores turn off the <link rel="preload">
without affecting the CSS or @font-face definition. f81e3f
swissup/module-gdpr — 1.9.2 (was 1.8.5)
-
Version 1.9.2 f4ad5b
-
Breeze: fixed visible built-in cookie notice 90033f
-
Version 1.9.1 5e0c58
-
PHP 8.5 fix ea894d
-
Add files via upload 5cabb8
-
Version 1.9.0 30de7e
-
Blocked Cookies => Unregistered Cookies cc499a
-
Show count of blocked cookies in the button label 0a3907
-
Ability to register selected blocked cookie 1d5955
-
Cleanup corresponding blocked cookies when saving the cookie 0fa019
-
Redirect back to the page where the client pressed "Allow selection" 3a28cd
-
Add notice about * in cookie names 423049
-
Cleanup blocked cookies using cron. Keep up to 100 latest records 4eca60
-
PHP 8.5 fix ca663e
swissup/module-hreflang — 1.6.12 (was 1.6.11)
-
Version 1.6.12 9a7e93
-
PHP8.5
ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect since PHP 8.1 4c3bc5 -
Magento 2.4.9 fix 4b4feb
swissup/module-navigationpro — 1.19.3 (was 1.17.35)
-
Version 1.19.3 421267
-
Do not use `is_active` from remote entity when creating new item
Since we have new option `use_remote_include_in_menu`, use it instead
of syncing `is_active` on item creation. 7c215a -
Fixed not working ribbon for vertical menu 8fdb50
-
LESS 5.3.0 compatibility 922d05
-
Version 1.19.2 c00164
-
Fixed unresponsive browser when toggling 7000 categories ef21c1
-
Version 1.19.1 b7ca42
-
Added ability to select all items in "Mass Edit" form 6f9251
-
Version 1.19.0 cb840c
-
Added ability to respect "Include in menu" setting 879226
-
Version 1.18.0 58f9cc
-
PHP8.5: Using null as an array offset is deprecated, use an empty string d87100
-
PHP8.5: Non-canonical cast (boolean) is deprecated, use the (bool) b74f7d
-
Ability to set gap for multicolumn layout 65b99a
-
If at least one link is marked as parent, highlight all other parents
(Event if they are empty) 7026c8 -
Breeze: reset dark colors only if header__dark = true 1032ef
-
Use CSS variables edeed5
-
Fixed not visible loading-mask because of Magento bug 7a6d30
-
Fixed incorrect column width in backend preview 27683b
-
Version 1.17.36 54f8f9
-
Fixed broken resizable columns 69ba9c
swissup/module-pagespeed — 1.19.0 (was 1.17.3)
-
Version 1.19.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 94fd39 -
fix(preload): cap font preloads and fix @font-face parsing (#117)
Font preloads had no cap and no notion of whether a face blocks rendering, while the url() parsing dropped most real-world forms and could aim a preload at a nonexistent host. Measured on a live Argento product page, 138 KB of preloaded fonts plus an eager 219 KB footer PNG stretched a 68 KB LCP JPEG into a 3.1 s download; removing them took LCP from 3.83 s to 2.11 s on Slow 4G.
- pagespeed/main/font_preload_limit (default 0, opt-in) caps the preloads per response
- pagespeed/main/font_preload_ignore skips urls by substring, for icon fonts
- candidates ranked by effective font-display: render-blocking first, then fallback, then swap; optional never preloaded
- url() values unquoted, fragments dropped, queries kept, hrefs mirroring the protocol-relative form the delivered css uses
- @font-face delimited and read on a comment-masked copy, with offset-based injection
- collection armed one-shot per process() call, so it no longer depends on caller order
Preload gained a Config constructor argument, so deploys need setup:di:compile.
Fixes #117 9a26a2 -
fix(preload): make font preloading from stylesheets opt-in (#117)
Default font_preload_limit goes from 2 to 0.
Fixing the url parsing means stores whose @font-face urls are quoted or
absolute — most of them — start getting font preloads they never had,
because the old code silently dropped those urls. Shipping a default of 2
would therefore add up to two High priority requests into the LCP window
on upgrade, which is the exact regression this branch exists to remove:
on the store that prompted it, 138 KB of preloaded fonts cost 1.7 s of
LCP. With font-display:swap injected the fonts do not block rendering
anyway, so the bandwidth buys little.
The budget and the ranking stay; an admin who knows which weight renders
above the fold raises it to 1 or 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6c93d -
fix(preload): make font collection one-shot per armed call (#117)
Sixth review pass pointed out that CssResolver calls process() without
ever calling setResponse(), so merged stylesheets never contribute
candidates — my claim that the ranking converges across every stylesheet
was wrong. Only the critical style blocks feed it.
Rather than widen collection, which would pull fonts out of every merged
stylesheet in a change meant to cap them, the gate is now honest and
deterministic. setResponse() arms exactly the next process() call, so a
bare process() never pushes. Left armed, whether a merged stylesheet
contributed depended on the order the two callers happened to run in for
a given request, which made the preload set vary between requests.
CriticalCss already chains setResponse() before each process() call, so
production behaviour is unchanged. The multi-call tests now re-arm the
same way, which also makes them mirror that caller.
Also pins the two remaining shapes of the mask's documented limit — a }
and a ; inside a quoted value — as misses rather than corruption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 95ecf9 -
docs(preload): record the comment mask's limit and pin its failure mode (#117)
The mask cannot tell a real comment from a comment opener inside a css
string or data uri, so a fake opener can blank a whole @font-face. That
limit is now stated at maskCssComments(), together with why a css
tokenizer is not worth carrying to read one descriptor.
The test pins the direction the failure takes: the face is skipped, so it
gets no injection and contributes no preload candidate, and the
stylesheet passes through unchanged. Skipping is acceptable; rewriting a
stylesheet we could not read is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 48133a -
fix(preload): delimit @font-face on a comment-masked copy (#117)
Fifth review pass.
A } inside a comment ended the face body early for the plain regex, so
@font-face{/* } */src:url(a.woff2)} yielded the body "/* " — the url was
never collected, and worse, the injection then ran
str_replace('/* ', 'font-display:swap;/* ') across the whole stylesheet.
The truncation predates this branch; the injection path made it harmful.
Comments are now blanked to spaces of the same byte length before the
faces are delimited, so a masked } cannot end a body while every offset
still points into the original text. Injection splices at those offsets,
from the end so earlier ones stay valid. That also fixes a second
injection bug: two identical face bodies in one stylesheet had their
swap injected by str_replace on the body text, which rewrote both and
then did it again on the next iteration.
Invalid duplicate descriptors no longer beat a valid one either. The
browser drops invalid declarations at parse time, so
font-display:optional;font-display:garbage stays optional; the last
valid value wins and auto applies only when nothing valid was declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7b87f1 -
fix(preload): validate the whole font-display value (#117)
Fourth review pass. The value pattern captured the leading alphabetic
token, so font-display: optional garbage read as optional and its
preload was suppressed — but the whole value is invalid css and the
browser falls back to auto, which blocks. Same for a descriptor written
with !important, which is dropped entirely.
The value is now taken up to the declaration terminator, trimmed, then
checked against the five valid keywords, so anything invalid resolves to
auto as the browser does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9ca057 -
fix(preload): read @font-face without its comments (#117)
Third review pass. A css comment may sit wherever whitespace may, so
font-family:A;/* keep */font-display:optional; hid the declaration from
the boundary matcher. The face then got a swap injected in front of a
declaration that stays effective, and was ranked as swap and preloaded
even though the author marked it optional.
Every read of a @font-face body now works on a comment-free copy, which
fixes the same class of bug in url extraction too: a commented-out
url(old.woff2) was being harvested as a preload candidate and could
spend the budget on a file the browser never requests. The injection
itself still targets the original text.
While there: a face declaring font-display twice resolved to the first
declaration, where the cascade takes the last.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d0f213 -
fix(preload): match font-display as a declaration, mirror scheme case (#117)
Second review pass found two more holes.
The injection guard tested for the substring "font-display" anywhere in
the @font-face body, so a family named after the descriptor or a font
path containing the word suppressed the swap injection and left the face
blocking. Both the guard and the value read now require the descriptor
at a declaration boundary — start of the body, or after a previous
declaration.
Scheme stripping accepted any spelling, but the css rewrite in process()
is a case-sensitive str_replace: HTTPS://host/f.woff2 stays absolute in
the delivered css while the preload became protocol-relative, which is a
different url on an http storefront. The href now mirrors that replace
exactly and keeps an unusual spelling absolute. The http(s) check also
moved off filter_var, which accepts file:// happily.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ba6b69 -
fix(preload): move the font budget to Preload and tighten parsing (#117)
Review follow-up. Four defects in the first pass:
Selection ran per process() call, but CriticalCss calls the improver
once per critical <style> block and the css resolver plugin once per
stylesheet. A swap face in the first block could spend the whole budget
before a render-blocking face in a later one was seen, and the same href
in two blocks consumed two slots. Candidates now accumulate in Preload
and the ranked selection happens in getAssets(), once, when every
stylesheet has been through the improver. Selection is pure, so
repeated getAssets() calls agree.
Deduplication kept the least urgent rank. Wrong: when one woff2 backs
two families, the family that blocks still blocks, so the file is
render-critical and the most urgent declaration has to win.
The font-display regex was case-sensitive and matched only the
alphabetic prefix of a value, so FONT-DISPLAY: OPTIONAL read as
render-blocking while the invalid optional-invalid read as optional and
was skipped. It is now case-insensitive, takes the whole token, and
resolves anything outside the five valid values to auto.
Preload hrefs forced https. process() rewrites absolute css urls to
protocol-relative, so on an http storefront the preload pointed at a
url the stylesheet never requests. Hrefs now mirror the delivered css:
protocol-relative for absolute and protocol-relative sources,
root-relative left alone, non-http schemes skipped.
Also adds declare(strict_types=1) to the new test file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8b22be -
fix(preload): cap font preloads and fix url() parsing (#117)
Improver pushed a preload for every woff2 it could parse out of a
stylesheet, with no cap and no check on whether the face blocks
rendering. Chrome fetches preloaded fonts at High priority, so each one
competes with the LCP image on the same connection and fetchpriority
cannot outrank it. Measured on a live Argento product page: dropping
138 KB of preloaded fonts (plus an eager 219 KB footer PNG) took LCP
from 3.83 s to 2.11 s on Slow 4G.
The url handling was broken in both directions. str_replace('//', '/')
ran before the validity check, so absolute and quoted urls were
silently dropped, while a root-relative url became https://<first path
segment> — a preload aimed at a host named "static". The cache-busting
query was stripped too, so a preload that did survive missed the css
request and the font downloaded twice.
- pagespeed/main/font_preload_limit (default 2, 0 disables) caps the
preloads per response, shared across every stylesheet processed
- pagespeed/main/font_preload_ignore skips urls by substring, for
decorative icon fonts
- candidates are ranked by effective font-display: render-blocking
first, then fallback, then swap; optional is never preloaded
- url() values are unquoted, fragments dropped, queries kept, and
root-relative hrefs left alone; hrefs are deduped
The budget lands together with the parsing fix on purpose: fixing the
parsing alone would start emitting the preloads that were being dropped
and ship the regression by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c16e23 -
Version 1.18.8 953e26
-
fix(preload): crossorigin="anonymous" is font-only and host-independent (#115)
crossorigin was added to any third-party resource hint based on host alone.
It switches the hint to a CORS-mode fetch, so a third-party script whose
server sends no Access-Control-Allow-Origin had its response blocked
(s.kk-resources.com/leadtag.js, #115). Even a permissive third party is hurt:
a CORS-mode hint never matches the no-cors <script> request, so the file is
fetched twice.
The host check was wrong in the other direction too — @font-face fetches in
CORS mode even from the store's own domain, so every same-origin font hint
was emitted without crossorigin and could not be reused by the real font
request.
crossorigin now depends on the asset type alone: fonts always, whatever host
they come from; scripts, styles and images never — they mirror the origin
element instead. An explicit crossorigin from the asset definition or from an
existing link is preserved; the rule only governs adding one. Applied in both
createPreloadLinkElement() and updatePreloadLinkInPlace(), so preload links
the theme or a tag manager already placed in <head> follow it too.
With the host out of the decision, isThirdPartySource(), getHost() and
getBaseUrlHost() are dead and removed, along with the StoreManagerInterface
constructor argument and the try/catch that only swallowed Laminas URI parse
errors.
Tests: 8 regression cases in AddLinkPreloadTest — third-party script, style
and image get no crossorigin; third-party and same-origin fonts keep it;
explicit crossorigin survives; existing in-head links follow the same rule.
Fixes #115
Closes #116 41be41 -
fix(preload): make font crossorigin host-independent (#115)
Review catch on the previous commit: @font-face fetches in CORS mode even
when the font sits on the store's own domain, so gating crossorigin on
"third-party host" left every same-origin font hint without the attribute
— the hint then cannot serve the real request and the font is downloaded
twice. That is the same failure this PR set out to remove, only pointed
at local fonts instead of third-party scripts.
crossorigin now depends on the asset type alone: fonts always, everything
else never. With the host no longer part of the decision, the whole
isThirdPartySource() / getHost() / getBaseUrlHost() chain is dead, and
with it the StoreManager dependency and the try/catch that only existed
to swallow Laminas URI parse errors.
- needsCrossorigin(string $as) — type-only decision
- Drop isThirdPartySource(), getHost(), getBaseUrlHost(), $baseUrlHost
- Drop the StoreManagerInterface constructor argument (DI resolves the
constructor by reflection, so no di.xml change) and the store mocks it
required in 28 test construction sites
- Drop the now-unreachable try/catch around both call sites
Tests: 2 more cases — a same-origin font hint, and a same-origin font
preload link already in <head>, both must keep crossorigin.
709 unit, 34 integration — all green. 225428 -
fix(preload): limit crossorigin="anonymous" to font hints (#115)
A third-party resource hint got crossorigin="anonymous" based on host
alone, with no regard for the asset type or for what the live tag asked
for. crossorigin switches the hint to a CORS-mode fetch, so for
s.kk-resources.com/leadtag.js — a script whose server sends no
Access-Control-Allow-Origin — the browser blocked the response outright.
Even a permissive third party is hurt: the CORS-mode hint never matches
the no-cors <script> request, so the file is fetched twice.
Only fonts are always fetched in CORS mode and therefore need the
attribute. Scripts, styles and images must mirror the origin element,
never invent it.
- Add CROSSORIGIN_TYPES = ['font'] and needsCrossorigin($as, $href)
- Use it in both createPreloadLinkElement() and
updatePreloadLinkInPlace(), so the rule also covers preload links the
theme or a tag manager already placed in <head>
- An explicit crossorigin from the asset definition or the existing link
is still preserved — the rule only governs adding one
The blanket rule dates back to e7a35a0 (2020), whose own comment noted
"add warning and more requests but increase score. why???".
Tests: 6 regression cases in AddLinkPreloadTest — third-party script,
style and image get no crossorigin; fonts keep it; explicit crossorigin
survives; existing in-head preload links follow the same rule. Three of
them reproduce the reported markup exactly against the old code.
Fixes #115 cc021a -
Version 1.18.7 d50f2d
-
chore: add repo-specific code-review skill for GitHub Copilot (#114)
GitHub's PR reviewer suggests adding an agent skill or MCP servers for
context-aware reviews. Give it one: the strategy-chain / patch-tracking
architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
matches, untracked DOM mutations) that a generic review won't know to
look for, on top of this repo's Magento 2 / phpcs conventions. 5021e3 -
chore: add repo-specific code-review skill for GitHub Copilot
GitHub's PR reviewer suggests adding an agent skill or MCP servers for
context-aware reviews. Give it one: the strategy-chain / patch-tracking
architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
matches, untracked DOM mutations) that a generic review won't know to
look for, on top of this repo's Magento 2 / phpcs conventions. 005217 -
fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036) (#113)
PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
against picking the wrong element by class alone, but SmartTagStrategy runs
earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
element whose attributes cleared a 70% similarity score, with no check for
a later, better (or exact) candidate.
Two product images in the same category grid share nearly every attribute
(class, style, loading, decoding, width, height, sizes, srcset) and differ
only in src/alt, comfortably clearing 70% similarity - so a patch meant for
the second image could land on the first one instead, reproducing the same
image-swap symptom the 1.18.3 fix was supposed to close.
SmartTagStrategy now scores every candidate and applies the best-scoring one
only when it isn't tied with another - same "lose the optimization rather
than corrupt the page" rule AttributeBasedStrategy already uses for class.
Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
first-match-wins behavior on a genuine tie, which is no longer correct. 0cf639 -
fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036)
PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
against picking the wrong element by class alone, but SmartTagStrategy runs
earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
element whose attributes cleared a 70% similarity score, with no check for
a later, better (or exact) candidate.
Two product images in the same category grid share nearly every attribute
(class, style, loading, decoding, width, height, sizes, srcset) and differ
only in src/alt, comfortably clearing 70% similarity - so a patch meant for
the second image could land on the first one instead, reproducing the same
image-swap symptom the 1.18.3 fix was supposed to close.
SmartTagStrategy now scores every candidate and applies the best-scoring one
only when it isn't tied with another - same "lose the optimization rather
than corrupt the page" rule AttributeBasedStrategy already uses for class.
Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
first-match-wins behavior on a genuine tie, which is no longer correct. e769d6 -
Version 1.18.6 9678b2
-
fix(test): resolve module and Magento root by walking up, not fixed depth (#112)
bin/test assumed the module sits exactly 5 levels below the docker root
and always tested the canonical vendor/swissup/module-pagespeed checkout
regardless of where the script was invoked from. Any checkout at a
different depth (a git worktree, a standalone clone) couldn't run the
suite, and even a resolved root would still test the wrong code.
Walk up from the script location to find the docker root (bin/clinotty +
src/), derive the module's path relative to that root, and invoke phpunit
via its absolute in-container path so a differing checkout depth no
longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
canonical path when the checkout isn't under src/.
tests/bootstrap.php had the same fixed-depth assumption and failed with
an unhelpful "Failed opening required ..." error. Walk up for
vendor/autoload.php instead, throw a RuntimeException naming the
searched path when not found, and derive the Logger/Image/minify
autoload map entries from the resolved root.
Fixes #111 f60df9 -
fix(test): resolve module and Magento root by walking up, not fixed depth
bin/test assumed the module sits exactly 5 levels below the docker root
and always tested the canonical vendor/swissup/module-pagespeed checkout
regardless of where the script was invoked from. Any checkout at a
different depth (a git worktree, a standalone clone) couldn't run the
suite, and even a resolved root would still test the wrong code.
Walk up from the script location to find the docker root (bin/clinotty +
src/), derive the module's path relative to that root, and invoke phpunit
via its absolute in-container path so a differing checkout depth no
longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
canonical path when the checkout isn't under src/.
tests/bootstrap.php had the same fixed-depth assumption and failed with
an unhelpful "Failed opening required ..." error. Walk up for
vendor/autoload.php instead, throw a RuntimeException naming the
searched path when not found, and derive the Logger/Image/minify
autoload map entries from the resolved root.
Fixes #111 c8861a -
fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack (#110)
fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
into two scripts specifically so the assignment stays synchronous. But that split runs before the main
processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
rewrites it to the deferred type exactly like the pre-split single script was, leaving
window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.
- shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
write) and skips retyping/moving it.
- fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
the split entirely.
Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
document, one reproducing the bug when it doesn't).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> 28291b -
fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack
fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
into two scripts specifically so the assignment stays synchronous. But that split runs before the main
processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
rewrites it to the deferred type exactly like the pre-split single script was, leaving
window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.
- shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
write) and skips retyping/moving it.
- fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
the split entirely.
Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
document, one reproducing the bug when it doesn't).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8ad474 -
Merge pull request #109 from swissup/claude/inspiring-elbakyan-cfe2e6
fix(dom): keep the page's own <meta charset> during optimization 3f00d8 -
fix(dom): keep the page's own <meta charset> during optimization
TrackedDomDocument injects <meta http-equiv="Content-Type" content="text/html;
charset=utf-8"> before loadHTML() so libxml interprets bytes as UTF-8, then
strips it again in its saveHTML() override.
addUtf8Charset() skips the injection when the page already declares a charset,
but removeUtf8Charset() stripped unconditionally, and stripMetaCharset()'s last
pattern matches *any* meta carrying charset=utf-8. On a page that declares its
own charset nothing was injected, yet the page's own declaration was deleted.
Magento\Framework\View\Page\Config\Renderer renders <meta charset="%content"/>
from the `charset` metadata on every page, so every optimized page lost its
charset declaration.
Not a libxml artifact and not the patch/strategy layer: libxml round-trips the
tag intact, the DOM node is never detached, and the loss reproduces on a bare
parse with zero patches recorded. It happened purely in the string
post-processing of saveHTML(), which is why the <head> opening tag stays
byte-identical.
Impact is usually masked because PHP appends charset to the Content-Type header
when default_charset is set. With default_charset empty the header carries no
charset and the meta is the only encoding signal, leaving the browser to guess
from its locale. Lighthouse also reports "Charset declaration is missing or
occurs too late", which a page-speed module should not be causing.
Fix: record what addUtf8Charset() actually inserted ('none', 'meta', 'head' or
'wrapper') and only strip when we injected something. Fragment-mode structure
restoration still runs, so the injected helper tag is still cleaned up.
Regression coverage in CharsetMetaPreservedTest pins the behaviour and the
cause: the charset survives DeferJs on the minisport production fixture, stays
the first meta in <head> and inside the first 1024 bytes, a non-UTF-8
declaration is never rewritten, the injected helper tag still never leaks, and
two guards assert that libxml preserves the tag and that the loss is
patch-independent so no replacement strategy can be blamed later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1951 -
Version 1.18.5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b3dcbb -
Merge pull request #108 from swissup/test/production-head-order-27428
test(defer): cover the stock-RequireJS production head 479d3d -
test(defer): cover the stock-RequireJS production head
Every real-page fixture in this suite is a Breeze page, and Breeze never renders
Magento's RequireJS bootstrap. Nothing covered the configuration the TTF-27428
failures live in, which is how 1.18.2 and 1.18.3 both shipped with a broken
bootstrap order.
Rather than hand-write the head, derive the asset order from the code that
produces it in production: Magento\RequireJs\Block\Html\Head\Config and our own
Block\Html\Head\Scripts, both run against a real asset Collection with
minification reported as enabled and JS bundling on. A developer-mode install
cannot produce this order at all -- Minification::isEnabled() short-circuits on
the app mode, so requirejs-min-resolver.js is never even rendered.
The resulting order matches what the affected store serves, and running the
released builds through it reproduces each shipped regression:
1.18.2 the global `var require = {baseUrl}` block and
requirejs-min-resolver.js are left behind while require.js, mixins and
requirejs-config stay in <head>. The global object then overwrites an
already-initialised window.require, which is the reported
"require.config is not a function", and the .min suffix is never
applied, which is the reported mage/requirejs/static.js 404.
5 of 7 tests fail.
1.18.3 our bundle pool and override.js are relocated and made async.
3 of 7 tests fail.
1.18.4 passes.
Refs TTF-27428
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d2c33 -
Merge pull request #105 from swissup/fix/fuzzy-content-strategy-head-corruption
fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy (#104) ac1fae -
fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy
Two bugs in apply() caused a last-resort patch to replace a <meta>
tag in <head> with a <body> element (e.g. <img>) when the patched
node's attribute value (alt, src, class ≥ 10 chars) appeared earlier
inside a head meta description's content="..." attribute.
Fixes #104 (radiatorkopen.nl: meta description overwritten by <img>).
Changes:
- Restrict strpos search to the <body> substring; <head> content is
never a valid match target for body-element patches.
- After expanding to the surrounding element, reject the match if the
element tag name differs from the patched node's tag name
(<meta> ≠ <img> → continue to next candidate).
Tests:
- testDoesNotReplaceHeadMetaWhenAltTextAppearsInMetaDescriptionFirst
reproduces the exact radiatorkopen.nl scenario (was failing).
- testReturnsNullWhenOnlyMatchIsInsideHead covers the variant where
the only document match is inside <head> (must return null). 772112 -
Version 1.18.4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> aa4b80 -
Merge pull request #102 from swissup/fix/requirejs-bundle-pool-order-27428
fix(defer): keep the bundle pool and our RequireJS patches in place 7aeb97 -
test: repair the typo'd DOCTYPE in the offset fixture
`<!` followed by a space is not a doctype, so libxml parsed it as a bogus
comment. With LIBXML_HTML_NOIMPLIED the parser then had no document element to
attach the real <html> to, emitting "misplaced <html> tag"; Magento's unit
bootstrap promotes that warning to an exception, so the test errored on every
run instead of asserting anything.
Also close the space inside the require.js src in the same fixture. That one did
not fail the test — the assertions only count //script[@src] and compare their
offsets — but the path was meaningless as written.
The three other fixtures in this file already spell the doctype correctly.
Fixes #103
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> cdbf37 -
fix(defer): narrow the RequireJS patch guard to override.js
The bootstrap guard matched the whole Swissup_Pagespeed/js/lib/requirejs/
directory, which pinned preload-all-scripts.js in <head> as well. That file is
not bootstrap: addPreloadAllScripts() add()s it at the end of the asset
collection rather than inserting it after the min resolver, and its entire body
runs inside a window load handler, so it cannot race require.config(). Guarding
it only costs a render-blocking head script on stores with Force RequireJS
Loading enabled.
Match 'Swissup_Pagespeed/js/lib/requirejs/override' instead, and correct the
comment: it described insert()-after-min-resolver placement that applies to
override.js alone.
testPreloadAllScriptsStaysOptimizable pins the reverse direction - the file must
still move to body end and take async past the counter threshold. It fails with
the directory-wide prefix and passes with the narrowed one.
Also assert asset presence while collecting positions in
testLiveHeadKeepsBootstrapChainInOrder: strpos returns false for a dropped
asset, which asort() sorts first, reporting a bogus ordering violation instead
of the missing script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e0e10 -
fix(defer): keep the bundle pool and our RequireJS patches in place
The 1.18.2 bootstrap guard was meant to cover the bundled module pool, but
'js/bundle/bundle' only matches Magento's own bundle0.js. Advanced JS Bundling
names its pool after the layout handle -- default-bundle0.js,
cms-page-view-bundle0.js -- so none of our bundles ever matched, and
Swissup_Pagespeed/js/lib/requirejs/override.js was not listed at all.
Combined with the 1.18.2 counter change, where skipped-but-executable scripts
consume a slot, these scripts crossed the jsCounter > 5 threshold and started
getting async -- something that did not happen before 1.18.2. Both call
require.config(): the bundle pool registers the jsbuild sources, and override.js
wraps require.config() and must be installed before requirejs-config calls it.
So on a store running Advanced JS Bundling or Interactive Defer, 1.18.3
introduced a fresh instance of the very failure class it fixed.
Match the whole js/bundle/ directory instead of a filename prefix (anything
living there is a module pool by construction) and add our own RequireJS patch
directory.
Regression coverage uses the head sequence dev2.dlastore.com actually serves,
with both features on: six ignored third-party inline blocks, the global require
config, then Magento's bootstrap interleaved with the bundle pool. The fixture
asserts the whole chain stays ahead of the body content, carries no async/defer,
and keeps its document order. It fails in seven places without the fix.
Refs TTF-27428
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1b477c -
Version 1.18.3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 82a806 -
Merge pull request #101 from swissup/fix/requirejs-bootstrap-coverage
fix(defer): cover every RequireJS bootstrap asset and stop counter drift 5a4389 -
fix(defer): count only executable scripts and cover global require config
Three follow-ups on the previous commit in this branch.
Counting every skipped script restored bootstrap coverage but also counted
inert blocks. text/x-magento-template, text/html and JSON payloads are never
executed by the browser, and Magento emits a handful of them on most pages
(minicart, checkout, swatches). Counting them inflated jsCounter and pushed
later scripts past the async (> 5) and third-party defer (> 1) thresholds, so
scripts that used to stay synchronous started getting async and losing their
execution order. jsCounter now advances only for script tags the browser
actually runs: an empty type, a JS MIME type, or the configured delayed type
(those are executed by the delayed-script runtime, so they keep their slot).
The inline bootstrap matcher only recognized the require.config() call. The
other legitimate form, a global `var require = {...}` / `window.require =
{...}` object emitted before require.js loads, carries the same
baseUrl/paths/map data and is under the same ordering constraint, so it was
still relocated behind the content and reproduced TTF-27428 on themes that
use it. Added REQUIREJS_INLINE_GLOBAL_PATTERN.
Inline matching is textual, not parsed, so a script mentioning the config
statement inside a string or a comment is opted out too. That direction is
deliberate - a missed optimization instead of a broken RequireJS bootstrap -
but it was documented as if only src matching had that property. Corrected the
docblock and pinned the behaviour with a test. Also gated both patterns behind
a stripos() check, since the matcher runs on every inline body including the
multi-hundred-kilobyte checkoutConfig payload.
Tests: 35 -> 42. Verified against the pre-fix code: 6 failures, one per gap
(inert-counter case plus five global-config spellings). With this change
705/705 green, phpcs 0 errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ee4c79 -
fix(defer): cover every RequireJS bootstrap asset and stop counter drift
PR #99 protected only requirejs/require.js, mage/requirejs/mixins.js and
requirejs-config.js. Magento renders up to eight bootstrap assets in <head>
(Magento\RequireJs\Block\Html\Head\Config::_prepareLayout), and the rest were
still relocated to the end of <body>:
- requirejs-min-resolver.js patches ctx.nameToUrl to append the .min sign and
is emitted whenever JS minification is on, i.e. in production
- mage/requirejs/baseUrlResolver.js + requirejs-map.js install the URL map
- mage/requirejs/static.js and js/bundle/bundle*.js define the module pool
Moving any of them behind the content reproduces the same failure class as
TTF-27428: module ids resolve against the bare baseUrl and static URLs are wrong.
Replace the five ignore signatures with isRequireJsBootstrap(), which matches
external assets on src only (an unrelated script merely mentioning a bootstrap
path in its code is no longer opted out of deferring) and inline blocks on the
require.config()/requirejs.config() call itself, tolerant to any spacing.
Also increment jsCounter for skipped scripts. Without it every ignore signature
shifted the unpack/async/defer thresholds of later scripts: with PR #99 only
three of six eligible inline scripts were still delayed on a page whose <head>
carries the bootstrap.
Extend RequireJsBootstrapOrderRegressionTest with a provider over all eight
minified asset names, seven inline-config spellings, a guard that a script only
mentioning a bootstrap path stays optimized, and a counter-stability assertion.
Against the previous code the new cases produce 9 failures; with this change the
suite is 698/698 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8ae870 -
Merge pull request #100 from swissup/fix/category-image-swap-xss-14036
fix(image): keep the processed marker out of the DOM (#xss-14036) 374f33 -
fix(dom): rank class last in attribute matching (#xss-14036)
Follow-up on review of the processed-marker fix.
AttributeBasedStrategy tried `class` before `src`, which is what let the
production patch land on a foreign product image in the first place. The
ambiguity guard alone does not cover a class that matches exactly one -
but wrong - element, so order the attributes from most to least
identifying and keep `class` as the last resort. Testing for a second
match from the first match's offset replaces the full-document
preg_match_all, and a skipped match is now logged instead of silently
dropping the optimization.
ProcessedNodeRegistryTrait no longer requires the consuming class to call
an initializer from its constructor: the storage is created on first use,
so a reader can never hit an uninitialized typed property. Added __clone
so a cloned document gets its own registry, and documented that entries
are keyed by PHP object identity and do not survive a node being rebuilt
from HTML.
PipelineCategoryImageSwapTest claimed to reproduce the image swap. It
does not: in a fixture that small OffsetBasedStrategy still resolves the
orphaned patch group, so AttributeBasedStrategy is never reached and no
image is swapped. Its src assertions are non-regression guards; what is
actually red before the fix is the dropped Lcp patch and the leaked
marker. Docblock now says so, and the swap stays pinned at unit level.
Tests: 673 tests, 2030 assertions, OK.
phpcs --standard=Magento2 on the touched files: 0 errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e9d02 -
fix(image): keep the processed marker out of the DOM (#xss-14036)
On a category page the first two product images were rendered with each
other's <img> tag. Reproduced on mcprod.boutinot.com/wines.html: item 1
links to CHV722 but shows CHV726, item 2 the reverse. Only the two images
that LCP marks with fetchpriority="high" were affected, and the wrong
output was then stored in the full page cache.
markImageAsProcessed() set data-pagespeed-processed through the raw
\DOMElement::setAttribute(), so the mutation was never recorded as a patch.
That made the oldHtml of every later patch of the same node stale, so
PatchGrouper could not chain it and the patch fell through to the fuzzy
replacement strategies. AttributeBasedStrategy then matched by
class="product-image-photo" - shared by every product image - and rewrote
the first product image with the second product's HTML.
Keep the marker in an SplObjectStorage registry on TrackedDomDocument
instead of in the DOM, and stop AttributeBasedStrategy from matching by a
class that several elements share. A patch that fails to apply only loses
an optimization; a patch applied to the wrong element corrupts the page.
Side effect: data-pagespeed-processed no longer appears in page output.
Nothing in JS, templates or tests reads it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> c5b7a3 -
Version 1.18.2 47a960
-
Merge pull request #99 from swissup/fix/requirejs-bootstrap-load-order
fix(defer): keep RequireJS bootstrap in place so map/paths apply first 9504be -
fix(defer): keep RequireJS bootstrap in place so map/paths apply first
DeferJs moved every <head> script to the end of <body>. That relocated
Magento's RequireJS bootstrap (requirejs/require, mage/requirejs/mixins,
requirejs-config) behind the body content, so early synchronous require([...])
calls executed before requirejs-config applied its map/paths. RequireJS then
resolved module ids against the bare baseUrl, requesting wrong static URLs
(e.g. domReady.min.js instead of requirejs/domReady.min.js). Browser-dependent
parse/execute timing made it surface in Chrome but not Firefox (TTF-27428).
Add the RequireJS bootstrap and inline require.config()/requirejs.config()
blocks to the defer ignore signatures so they are neither moved, deferred, nor
made async — same mechanism already used for NREUM.
Add RequireJsBootstrapOrderRegressionTest guarding the load-order invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> ec4081 -
Version 1.18.1 31e2a3
-
Merge pull request #98 from swissup/fix/directmatch-mbstrpos-perf
Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches f71c7a -
Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches
DirectMatchStrategy::findAndReplace branched to mb_strpos()/mb_substr() for
any non-ASCII oldHtml. On PHP 8.2 mbstring that path is ~O(n*m): a Breeze
enterprise product page produced three merged body-level patches (~310-388KB,
containing "£"), each taking ~8s in DirectMatch (which then failed the match
anyway), accounting for ~24s of the ~29s page-generation time.
Use byte-based strpos()/substr_replace() unconditionally. This is correct for
UTF-8 because UTF-8 is self-synchronising: a valid substring's byte sequence
only matches at a character boundary, and substr_replace() operates on byte
offsets. Also removes the now-dead charPosToBytePos() helper.
Verified on staging: page generation 32s -> 6.6s. Full module test suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 779231 -
fix(preload): default missing as attribute to style on existing links
In-place update (1.18.1) stopped writing the "as" attribute that the
old re-create path always set. rel="preload" without a valid "as"
triggers browser warnings and may double-fetch the resource.
- add regression test for the missing-as default
- rename testProcessRemovesDuplicatePreloadLinks to match the
keep-in-place semantics; drop stale comments
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 47d617 -
fix(preload): keep existing preload links in place to preserve CSS order
AddLinkPreload removed every existing rel="preload" link and re-inserted
it right after <title>, above the critical <style>. Deferred stylesheets
(converted by DeferCss, with onload flipping them back to
rel="stylesheet") ended up before critical CSS in the DOM, inverting the
cascade: critical CSS overrode the full theme CSS after load.
- Existing preload links keep their DOM position; per-type rel,
fetchpriority (within limits) and crossorigin are updated in place
- Only new preloader assets are inserted after <title>; href dedupe
against existing links and the live-script prefetch guard are kept
- Replace applyPreloadChanges() with updatePreloadLinkInPlace() +
insertPreloadLinks()
Tests: add regression tests for link position relative to critical
<style>; suite OK (644 tests, 1944 assertions).
Fixes #97
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 07ef75 -
fix: replace deprecated SplObjectStorage::contains() for PHP 8.5
PHP 8.5 deprecates SplObjectStorage::contains(); with an error handler
that escalates deprecations (e.g. Ignition) this kills page rendering
inside the optimizer pipeline. Use offsetExists() — identical
semantics, available since PHP 5.3.
Fixes #96
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> cee3e2 -
Version 1.18.0 75bd79
-
test: add attribute metadata for PHPUnit 12 discovery
PHPUnit 12 dropped doc-comment metadata: @dataProvider providers were
not applied and @test snake_case methods were not discovered. Add
#[DataProvider]/#[Test] attributes while keeping the annotations so
the suite still runs on PHPUnit 9.6 (CI) and 10.5 (local), which
ignore unknown attribute classes.
Also accept the unused third data-set argument in testGetFilename —
PHPUnit 12 warns when a provider passes more arguments than the test
method accepts.
Verified: PHPUnit 12.5.31 exit 0 (631 tests), PHPUnit 10.5.47 OK.
Refs #92 5b20fe -
feat!: remove Expire Header feature
BREAKING CHANGE: Body\Expire optimizer, `pagespeed/expire/*` config
and its admin section are removed.
The feature set `Cache-Control: public, max-age=31536000` on document
HTML for guest pages. Browsers and hosting-side proxies (invisible to
the Varnish config check) cache such pages beyond any server-side
flush, so merchants could not push content/price updates to returning
visitors for up to a year. No PageSpeed/CWV benefit to offset that.
Orphaned core_config_data rows are harmless.
Closes #91 f659e6 -
chore: ignore phpunit cache and playwright artifacts be8bda
-
test: drop APIs removed in PHPUnit 12 from Test/Unit
- replace getMockBuilder('stdClass')->addMethods() with createMock()
of real classes (Store, Minify_HTML, HeaderInterface)
- validate optimizers before logger init in Pipeline so expected
InvalidArgumentException surfaces even without logger classes
- register module-logger, module-image, mrclay/minify in tests
bootstrap autoloader (absent from standalone test autoload)
Refs #92 5393a1 -
fix(preload): prevent double download of scripts already in DOM
DeferJs: remove addPreloadLinks() entirely — every external <script src>
stayed live in the DOM while also getting a <link rel=prefetch> hint,
causing the browser to fetch the file twice (confirmed: CookiePal,
Elfsight, requirejs-config-breeze on mobile).
- Remove Preload dependency, preload property, addPreloadLinks() method
and all 4 call-sites from DeferJs
- Remove script src collection in prepareElement()
AddLinkPreload: add defensive liveScriptSrcs guard — skip any script
href that already exists as a <script src> tag in the DOM, regardless
of how it ended up in the Preload singleton.
Tests: remove preloaderMock from DeferJsTest + DeferJsRegressionTest;
add testScriptsAlreadyInDomAreNotPrefetched and
testScriptsNotInDomCanGetPrefetchHint to AddLinkPreloadTest.
Fixes #95 4b539f -
fix(preload): per-type rel/fetchpriority; honor isEnabled config
- Replace global priority counter with per-type counters
- ALWAYS_PRELOAD_TYPES: font/style/image always rel=preload; scripts=prefetch
- FETCHPRIORITY_LIMIT_BY_TYPE: null=unlimited (fonts), 2 styles, 1 image (LCP), 0 scripts
- Fix isEnabled() hardcoded true -> config->isLinkPreloadEnabled()
- Use array_key_exists to correctly handle null sentinel vs missing key (null??0 bug)
- Add tests: image=preload, scripts=prefetch, per-type fetchpriority limits, isEnabled toggle
Fixes #93 #94 84b155 -
feat: add dedicated cache type to isolate optimizer entries from block_html
- Add Model/Cache/Type.php: new Magento cache type (swissup_pagespeed)
- Add etc/cache.xml: register type with label/description
- Update CacheManager: use Type::TYPE_IDENTIFIER instead of block_html,
add Type::CACHE_TAG to entry tags for proper TagScope scoping
Fixes Redis Default Cache pollution where 1.2M+ SWISSUP_PAGESPEED entries
were mixed into the block_html cache type. Optimizer cache is now
independently manageable in Stores > Cache Management. 9a552e -
Version 1.17.8 fdc783
-
fix: correct double-backslash handling in ScriptTagEscaper JS parser
A double backslash (\) in a JS string escapes the backslash itself,
not the following character. The old check 'prevChar === \' incorrectly
treated the char after \\ as escaped, causing findClosingScriptTag to
never exit the string and escape the entire remaining HTML including
</body> and </html>.
Replace single-char prevChar check with isPrecededByOddBackslashes()
helper that counts consecutive preceding backslashes: odd = escaped,
even = not escaped. Applied to both the skip-escaped-char guard and
the string-delimiter detection. 995cee -
fix: prevent DeferJs from moving New Relic (NREUM) scripts out of head
NREUM scripts must execute synchronously in <head> before any other JS.
DeferJs was moving them to body end, breaking New Relic initialization.
- Add NREUM signatures to DeferJs ignore list (window.NREUM, NREUM.init,
NREUM.info, NREUM.loader_config)
- Remove overly broad NREUM check from AfterRenderResultPlugin that
disabled ALL optimization when any page content contained 'newrelic'
- Add position-based tests that fail without the fix and pass with it 174268 -
Fixed script parsing issue caused with New Relic (Unexpected token '<') 2ebe4d
-
fix: add TTL to optimizer cache entries in CacheManager (30-day lifetime) 4b6e50
-
fix: remove block_html tag from optimizer cache entries in CacheManager (close #90) 445044
-
Version 1.17.7 aa19e8
-
Add support for native Magento 2.4.9 dev/js/defer_non_critical option 3f9c2c
-
Magento 2.4.9 fix (CLI execute command) fdc98a
-
fix: replace Zend_Cache constant with string for Magento 2.4.9 compat 9a8169
-
Version 1.17.6 edbcbc
-
refactor: update module-logger to ^2.0, remove from module sequence a8cca6
-
Add bin/test script for running unit and integration tests 8ae030
-
Extract Logger into swissup/module-logger, update namespaces and dependencies cc376b
-
fix: improve interaction mode in requirejs override
- fix 'touch' event to 'touchstart' for proper mobile support
- add requestIdleCallback(userInteractionListener) as idle fallback
- add setTimeout fallback (2s) to ensure JS loads without interaction
- fix DOMContentLoaded race condition via readyState check
- extract init() to avoid logic duplication
- remove dead commented code 95e010 -
Version 1.17.5 b68149
-
fix: remove new-in-initializer default to fix Magento DI compilation
PHP 8.1 'new in initializers' syntax in IndexedAttributeOffsetFinder
caused Magento DI compiler to serialize TagIndex via var_export(), which
requires __set_state(). Fixed by removing the default value and passing
$tagIndex explicitly from TrackerFactory and test setup trait. 9c8e61 -
Merge branch 'master' of github.com:swissup/module-pagespeed 0621b9
-
Version 1.17.4 33e6c5
-
fix(tests): replace @$dom->loadHTML error suppression with libxml_use_internal_errors 594c79
-
refactor: address code review — rename SiblingIndexCalculator, deduplicate buildIndex, add docs
- Rename SiblingIndexCalculator → GlobalTagIndexCalculator to reflect that it
calculates a document-wide index, not a parent-relative sibling position
- TagIndex: extract maskScripts() as public method; expand TRACKED_TAGS to include
'style' and MATCH_ATTRIBUTES to include 'data-role', 'data-block'
- IndexedAttributeOffsetFinder: remove duplicated buildIndex/extractAttributes,
delegate to TagIndex via constructor injection; remove commented-out is_duplicate guard
- PatchGrouper: document why maxPasses = count(orphanChains) is the correct upper bound
- PipelineEasyslideImgLostTest: add @group bisect to testFixtureBinarySearch;
fix verify_peer: false → true in DOWNLOAD_PROD_HTML stream context 2f337b -
fix: replace fwrite(STDERR) debug with logDebug, clean up debug tests, add regression tests for orphan picture/img bug
- PatchGrouper: replace raw $dbg/fwrite(STDERR) in foldOrphanChains() with logDebug() wrapped in isDebugEnabled()
- PatchGrouper: use count($orphanChains) instead of magic number 10 for maxPasses
- Add PipelineEasyslideImgLostTest: integration regression tests for <picture> losing <img> after WebP conversion
- Add production HTML fixture magento1609274-homepage.html.txt for integration tests
- Remove PatchGrouperDebugTest (inspection-only debug test, no real assertions)
- Remove testTrailingNewlineEffect and testDebugFullFixture (debug/inspection tests) 83b5e3 -
fix: fold orphan DOM chains so <picture> always contains <img> after WebP conversion
When WebP optimizer wraps an <img> in a <picture>, the new <picture> element
and its parent container are both 'orphan' patch chains (offset=null) because
neither is in the DOM position index. The old inlineOrphanChain only searched
anchored groups, so the <picture> patches were never applied and <picture>
ended up without an <img> child on pages like Hero Slider with multiple slides.
Introduce foldOrphanChains(): a multi-pass algorithm that folds child orphan
chains into parent orphan chains by string-matching the child's intermediate
HTML snapshot inside the parent's newHtml. After folding, the parent chain
carries the fully-resolved <picture><source>...<img></picture> HTML and
is emitted as a standalone group in the correct position.
Add two regression tests that call indexNodes() before processContext() to
reproduce the bug and verify the fix across single- and multi-slide layouts. fb1b3a -
fix: correct WebP picture/img offset calculation for pages with script templates
- SiblingIndexCalculator: use global getElementsByTagName() index instead of
local previousSibling iteration so sibling index matches TagIndex candidates[]
- TagIndex + IndexedAttributeOffsetFinder: mask <script> block bodies before
regex scanning to prevent fake <img> inside Knockout/text-html templates from
polluting the candidates array and shifting sibling indexes
- ParentContextOffsetFinder: introduce calculateLocalSiblingIndex() that counts
position among siblings within the immediate parent (not document-wide),
fixing navigation <span> offset resolution
- SiblingIndexTagOffsetFinder: restore is_duplicate guard so duplicate opening
tags defer to ContentPriorityOffsetFinder for content-based disambiguation
- WebPTest: add regression tests for easyslider mixed eager/lazy slides and
for pages with script-template fake <img> nodes (magento-1609274 production bug) a1dfec
swissup/module-pro-labels — 1.10.1 (was 1.8.1)
-
Version 1.10.1 c44745
-
Add null check for associated products in getCheapestFromGrouped method 9156db
-
Version 1.10.0 79b19f
-
Restore deprecate getProductLabels method for compatibility with older versions (close #47). 85974e
-
Refactor models (#46)
* Refactor Data, LabelsProvider, ManualLabelsProcessor, and SystemLabelsProcessor to use ProductLabels helper and improve type hinting
* Refactor Catalog helper to improve type hinting and streamline constructor properties
* Refactor AbstractLabel and ProductLabels helpers to improve type hinting and streamline method signatures
* Refactor LabelInterface and Label model to improve type hinting and streamline method signatures
* Address Copilot suggestions. 072cea -
Address Copilot suggestions. 5a19dd
-
Refactor LabelInterface and Label model to improve type hinting and streamline method signatures f5a2e2
-
Refactor AbstractLabel and ProductLabels helpers to improve type hinting and streamline method signatures fac13c
-
Refactor Catalog helper to improve type hinting and streamline constructor properties bd402b
-
Refactor Data, LabelsProvider, ManualLabelsProcessor, and SystemLabelsProcessor to use ProductLabels helper and improve type hinting 26bb79
-
Refactor renderLabels method to use require for prolabels module 067179
-
Feature/active period (#45)
* Add from_timestamp and to_timestamp fields to Label model and interface
* Add isActive function and filterLabels method to manage label visibility based on timestamps
* Implement AMP helper integration and enhance label fetching logic.
Fetch only active labels when AMP storefront is enabled.
* Address Copilot suggestions. c7e128 -
Address Copilot suggestions. d937b5
-
Implement AMP helper integration and enhance label fetching logic.
Fetch only active labels when AMP storefront is enabled. 2f1652 -
Add isActive function and filterLabels method to manage label visibility based on timestamps bc3140
-
Add from_timestamp and to_timestamp fields to Label model and interface bec56c
-
Version 1.9.2 54e250
-
Fixed ParseError error with template minification enabled d87fce
-
Version 1.9.1 8cf2b6
-
Fixed PHP<8.3 support (Typed const) 02e1ce
-
PHP8.5: Using null as an array offset is deprecated, use an empty string 6402e3
-
Fix call to undefined method preloadManualForProducts (close #43) 329b16
-
Magento 2.4.9 fix 2ad4a7
-
Refactor LabelsProvider and related services for improved structure and clarity; add ManualLabelsProcessor and OutputStrategyProcessor 19cc65
-
Refactor PHP templates to improve code structure and add strict types db06bb
-
Add media gallery toggle functionality and enhance CSS for disabled state e30cd6
-
Update templates and JavaScript for enhanced preview features; make it clearer and simplier. 3de63d
-
Remove obsolete file uploader fix for Magento 2.3 from prolabels_label_edit.xml 980ce3
-
Refactor CategoryLabelFieldset and ProductLabelFieldset for improved code clarity and structure 0acc63
-
Refactor image control handling and add dynamic stylesheet loading de2a78
-
Version 1.9.0 2d302a
-
Use "Upload + Media gallery" component for prolabels image (#42)
* Use "Upload + Media gallery" component for manual prolabels edit form.
* Add Media Gallery to New Labels on product page.
* Add UpdateImagePath data patch to update image paths for system and manual labels
* Refactor image field configuration to use MediaGallery frontend and backend models for category and product settings
* Fix URL cleanup for existing images in ImageUploaderService
* Add null checks in MediaGallery for safer image data handling
* Fix not working preview for system labels.
* Update image path resolve on storefront.
* Fix MCS.
* Address Copilot suggestions. 86e25e -
Address Copilot suggestions.
Co-authored-by: Copilot <copilot@github.com> 4c25b9 -
Fix MCS.
Co-authored-by: Copilot <copilot@github.com> 558846 -
Update image path resolve on storefront.
Co-authored-by: Copilot <copilot@github.com> d74c2d -
Fix not working preview for system labels.
Co-authored-by: Copilot <copilot@github.com> 94f32c -
Add null checks in MediaGallery for safer image data handling ad0851
-
Fix URL cleanup for existing images in ImageUploaderService 819ab3
-
Refactor image field configuration to use MediaGallery frontend and backend models for category and product settings
Co-authored-by: Copilot <copilot@github.com> 12c1c2 -
Add UpdateImagePath data patch to update image paths for system and manual labels
Co-authored-by: Copilot <copilot@github.com> ae4b4f -
Add Media Gallery to New Labels on product page.
Co-authored-by: Copilot <copilot@github.com> 7f8cb3 -
Use "Upload + Media gallery" component for manual prolabels edit form.
Co-authored-by: Copilot <copilot@github.com> 91c141
swissup/module-quantity-switcher — 1.2.4 (was 1.2.1)
-
Version 1.2.4 e4c020
-
Merge pull request #5 from swissup/fix/arrow-room-lost-after-mount
fix(arrows): keep the arrow room after the switcher mounts 1a9480 -
fix(arrows): keep the arrow room after the switcher mounts
`.control:has(> .input-text.qty)` only matches the server-rendered markup.
`product.js` wraps the input into `.qty-wrapper` on init, after which the input
is no longer a direct child of `.control`, the rule stops matching and the 20px
reserved on each side for the arrows disappears — the arrows overflow the field
and the layout shifts once JS runs.
Match the input as a descendant instead, which holds both before and after the
wrap. Verified in the browser: `.control` padding went `0px 20px` -> `0px` the
moment the wrapper was inserted, and stays `0px 20px` with this selector.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 082597 -
Version 1.2.3 f2d44f
-
Fixed MSI error for bundle products (with no MSI source) on the cart page aa1c46
-
Version 1.2.2 0797d6
-
Fix TypeError when product type_id is null in getMaxQty 48a93f
swissup/module-rich-snippets — 1.8.5 (was 1.7.18)
-
Version 1.8.5 2685fe
-
Refactor Offers class to use PriceFormatter for price formatting (#43) 830f60
-
Enhance AdditionalProperty handling by adding 'id' field and include it in structured data as 'propertyID' (#43) c05490
-
Version 1.8.4 3c45a4
-
Add regex validation to modifier modal and alert on invalid patterns d3f200
-
Fix issue with not saving of reordered modifiers. 7f18af
-
Add regex modifier for PCRE find-and-replace functionality (#43) d10bd9
-
Add strip_pagebuilder modifier to remove Page Builder markup (#43) 7a6f70
-
Version 1.8.2 5ee8bc
-
PHP8.5
ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect since PHP 8.1
Using null as an array offset is deprecated, use an empty string instead e38455 -
Refactor modifier modal and builder manager to improve event handling and modal interactions
Co-authored-by: Copilot <copilot@github.com> 30beba -
Version 1.8.1 bc23a1
-
Remove unused modal dependency from modifier builder 4060db
-
Version 1.8.0 d81a93
-
Refactor AdditionalProperty and implement visual modifier builder (#42)
* Modifiers for AdditionalProperty
* Refactor AdditionalProperty and ModifiersRenderer for improved modifier handling and UI enhancements
* feat: Implement visual modifier builder with modal interface
* Address MCS errors and warnings.
* Address Copilit suggestions. fc4df1 -
Address Copilit suggestions. c01f14
-
Address MCS errors and warnings. 488ddc
-
feat: Implement visual modifier builder with modal interface ab2311
-
Refactor AdditionalProperty and ModifiersRenderer for improved modifier handling and UI enhancements e9cfbf
-
Breeze: Fixed not working breadcrumbs when merge is enabled 5150a7
-
Modifiers for AdditionalProperty fbdf7b
-
Add AdditionalProperty support for structured data and configuration (#41) c2dc26
-
Add skipCrosslinksProcessing parameter to productAttribute method ed9877
-
Added 'Local Business' section c7d12a
swissup/module-seo-canonical — 1.11.10 (was 1.11.9)
-
Version 1.11.10 c94f86
-
Refactor type hints and return types in Config, Data, and UrlMaker classes af4b62
-
Fallback to current store when product is not assigned to forced store (close #2) 71c8ec
swissup/module-seo-cross-links — 1.2.0 (was 1.1.26)
-
Version 1.2.0 680e1b
-
To previous commit. e9e9f3
-
Fix for blocks content.
Search in cms content to filter blocks. 5f2177 -
Fix php stan. 4d26b3
-
Support CMS block. e097d4
swissup/module-seo-html-sitemap — 1.1.23 (was 1.1.21)
-
Version 1.1.23 75c59b
-
Added Hide Attribute Pages config c0dedb
-
Version 1.1.22 6b6b09
-
Added ability to set custom sitemap URL per store; added canonical tag d738b7
swissup/module-seo-pager — 1.4.0 (was 1.3.5)
-
Version 1.4.0 0b9adc
-
Refactor Renderer class: streamline constructor properties and extract request variable logic into a separate method 5831e2
-
Enchance allowlist filter sets configuration for strategy. Update canonical URL. Close #6 07b704
-
Refactor Data helper: improve type hints and use constants for configuration paths c0f932
-
Add allowlist filter sets configuration for strategy (#6) 38c8b2
swissup/module-seo-templates — 1.7.12 (was 1.7.10)
-
Version 1.7.12 4f7a81
-
PHP 8.5 fix 014b8a
-
Version 1.7.11 485c39
-
Add index to swissup_seotemplates_data for improved query performance (close #22) 5c24d9
-
Introduce FilterValueResolver service to make module compatible with Smile Elasticsuite (close #21) d54a51
swissup/module-seo-urls — 1.5.55 (was 1.5.54)
swissup/module-seo-xml-sitemap — 1.2.0 (was 1.1.16)
-
Version 1.2.0 c26fd8
-
Refactor Category plugin for improved type safety and code clarity. Drop Magento2.1.x support. 1c398c
-
Feature/product min lastmod (#6)
* Refactor Category plugin for improved type safety and code clarity. Drop Magento2.1.x support.
* Add product options configuration and date handling in admin settings
* Add forced minimal lastmod date handling for products in sitemap
* Refactor Category plugin to use Helper for image include policy retrieval
* Add strict types and improve type safety in Data helper methods
* Refactor XML configuration for sitemap settings by modularizing into separate files for categories, pages, other links, and advanced settings.
* Address Copilot comments 1fc7bc -
Address Copilot comments f7bf57
-
Refactor XML configuration for sitemap settings by modularizing into separate files for categories, pages, other links, and advanced settings. 55e24f
-
Add strict types and improve type safety in Data helper methods 782f41
-
Refactor Category plugin to use Helper for image include policy retrieval 386e7d
-
Add forced minimal lastmod date handling for products in sitemap e69cb2
-
Add product options configuration and date handling in admin settings 372385
-
Refactor Category plugin for improved type safety and code clarity. Drop Magento2.1.x support. d7935f
swissup/module-sold-together — 1.11.4 (was 1.11.3)
-
Version 1.11.4 884c4f
-
PHP8.5
ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect since PHP 8.1 77cd08 -
PHP8.5: Non-canonical cast (boolean) is deprecated, use the (bool) 75227c
-
Magento 2.4.9 fix (CLI execute command) a8f141
-
Fixed ability to show `Customer` block on the shopping cart page 3b7a4e
swissup/module-sold-together-import-export — 1.0.2 (was 1.0.1)
swissup/module-testimonials — 1.4.2 (was 1.4.0)
-
Version 1.4.2 4c12e4
-
Fixed wrong output when rating is not set 64ce08
-
Added show image config and more options for visible slides dropdown 283b05
-
Version 1.4.1 8ca632
-
Slider widget: added arrows/dots configuration 68ea17
swissup/theme-frontend-argento-blank — 1.49.5 (was 1.49.4)
-
Version 1.49.5 a40b6d
-
Remove call of deprecated method getProductLabels from product list template 23db6e
-
Sync actions template with magento to reuse `addto` block 7f9662
-
Revert "Magento 2.4.9 fix"
This reverts commit 49e4b6c4385c2d5ac80c8fa936375c232cc3d957. 8806ef -
Magento 2.4.9 fix 49e4b6
swissup/theme-frontend-argento-chic — 1.48.3 (was 1.48.2)
swissup/theme-frontend-argento-essence — 1.46.4 (was 1.46.3)
swissup/theme-frontend-argento-flat — 1.46.6 (was 1.46.5)
swissup/theme-frontend-argento-force — 1.48.3 (was 1.48.2)
-
Version 1.48.3 8f3a16
-
A11y fixes 6fa2ec
-
Accessibility: Heading elements are in a sequentially-descending order a25017
-
Update critical CSS 8424b6
swissup/theme-frontend-argento-home — 1.48.5 (was 1.48.4)
swissup/theme-frontend-argento-luxury — 1.48.2 (was 1.48.1)
-
Version 1.48.2 fdc6ed
-
Update critical CSS c17fd8
-
Better fix in ajaxsearch module 0d021f
-
Update ajaxsearch integration 33833a
swissup/theme-frontend-argento-mall — 1.48.2 (was 1.48.1)
swissup/theme-frontend-argento-marketplace — 1.48.4 (was 1.48.3)
swissup/theme-frontend-argento-pure2 — 1.46.6 (was 1.46.5)
swissup/theme-frontend-argento-stripes — 1.48.3 (was 1.48.2)
swissup/module-core — 1.13.1 (was 1.12.26)
-
Version 1.13.1 5b7b24
-
Fixed invisible update instructions when Mollie Payments is installed 72aab6
-
Version 1.13.0 f34c7b
-
Fill the links after ajax refresh b6a316
-
Show loading state on the "Check for Updates" button 1ab16e
-
Use optimized repo to fetch latest releases 1aa8fb
-
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a -
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2 -
Mark core section with badge when outdated modules found 9a3764
-
Outdated modules badge and Modules config group (#25) 7df026
-
MCS fix 3b9121
-
Update instructions 0e686f
-
Read links info from marketplace meta 21e95b
-
Keep the last check time when a refresh fails
Removing the stored time was what made the next load re-check the feed.
An unreachable feed left no time at all: the config page had nothing to
display, and every page load kept re-checking until one request made it
through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9 -
Post the modules refresh to an action of its own
The refresh drops the version check throttle and re-reads the remote
source, and a get carries no form key validation - Magento checks it for
post requests only, and the url secret key is optional. Reading the list
stays a get, so the two live in separate actions now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9 -
Keep the module links out of the response
The links cell is rendered by the server and never touched by js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db -
Send only the module metadata the browser renders
The merged Loader record carries the local filesystem path and the
download, license and purchase-code fields of the remote feed. None of
them is rendered by the config page, so keep them on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425 -
Too much red 2e307e
-
Use the same `outdated` class name everywhere
The version span was already renamed, while the row and the js still said
`_outdated` - a row that stopped being outdated kept its red version pill.
Since the row now wears the very same class, the pill rule had to be
scoped to the version cell, or the whole row would turn red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086 -
Report the check time with the modules, and keep outdated rows first
Two things the clients were guessing at:
The `Check for Updates` handler stamped the label with the current time,
but the request may have re-used the stored packages after a failed feed
request - `just now` was not always true. And an automatic load did check
the remote source, yet nothing updated the label at all. The endpoint now
returns `['items' => ..., 'last_check' => ...]`, and the label follows the
time the server reports for every load.
The table was re-rendered cell by cell, so a module that became outdated
kept its old position while the counter above it grew. Mark the rows and
move the outdated ones back to the top - both groups stay sorted by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271 -
Keep the last check time once the check is due
The timestamp was stored with a one hour lifetime and the throttle was
`is the entry still there?`. Handy, but it also meant the config page
could no longer tell how old the data was as soon as the check was due -
exactly when that is worth showing next to `Check for Updates`.
Store it without a lifetime and compare the age instead. Same hour
boundary, and the label now reads `Last checked 2 h ago` instead of
disappearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a -
Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66
-
Hovered color for outdated badge 0a0016
-
Show outdated modules count in config and add Modules group
The admin had no hint that installed Swissup modules have updates
available - one had to open Module Manager to find out.
Add a counter badge to the Swissup tab of the config navigation, and a
Modules group in Swissup > Core listing every installed module with its
installed and latest version, outdated ones first.
Neither of them may slow down the config page, so both render from the
data stored in var/swissup/core (Loader::setOfflineMode()), and the
versions, the counters and the badge itself are then refreshed by a
single ajax request shared by all the consumers. `Check for Updates`
re-runs that request with refresh=1, which drops the version check
throttle and re-reads the remote source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6 -
Move core config to the top as it will display outdated modules 115f37
-
Added ability to immidiately re-check remote source for updates `-r` 357d33
-
Keep the remote packages data in files instead of the cache (#24) cbeecc
-
Trim the comments down to what is not in the code
The two lock docblocks carried the same paragraph, and save() explained
the reasoning that belongs in the commit that introduced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9 -
Serialize the writes with a lock taken before the entry is opened
openFile() truncates whatever it opens, so a lock taken afterwards keeps
the writes apart but not the truncations. Two saves could interleave into
one file: the second one truncates, the first one writes a whole entry,
and the second one then overwrites its beginning - leaving the head of
one entry on the tail of another. The length line does not catch that
when both entries encode to the same size, as fixed width values like a
version hash always do.
Take the lock before opening, on a file of its own - the entry cannot be
opened before the lock is held, and the file lock() uses is likely held
by the caller already, which flock would deadlock against.
Failing to open a lock file is no longer reported as a lock held by
somebody else. A lock left behind by another user cannot be opened at
all, and reporting that as contention kept the caller away from the
entry for good - silently serving a list that could never be refreshed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28 -
Don't download the list that another process has just stored
The version is fetched before the download lock is taken, so a process
that was still asking for it when the lock was released would go on to
download the very list the lock holder had just stored. Look at the
storage once more before downloading.
Also correct what the save() lock claims to do: it serializes the
writes, but the truncation happens in openFile(), outside of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0 -
Reject an entry whose expiration time is not a number
A corrupt header such as `expires:invalid` was cast to zero, which reads
as "never expires" - so an entry that should have gone stale could be
served indefinitely instead of being treated as missing. Validate the
field the same way the length line is validated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16 -
Keep the remote packages data in files instead of the cache
The packages list was kept in the Magento cache, so every cache flush
threw it away and the next admin request had to download it again. It is
now stored under var/swissup/core, where it outlives the flush.
FileStorage writes the expiration time and the data length ahead of the
contents, so an entry knows itself when it goes stale, and a reader can
tell a half-written entry from a complete one - openFile() truncates the
file before the write lock is taken, so a reader can catch the entry
mid-save. An incomplete entry is reported as missing and refetched,
rather than served as if it were whole.
Remote revalidates against the version hash from packages.json, at most
once an hour, and only downloads the full list when that hash moves. The
download itself is guarded by a non-blocking lock, so several admins
hitting the page at once produce one request instead of one each - the
others serve the stored copy, or wait for the download when there is
nothing stored yet. fetch() now reports connection errors and 4xx/5xx
responses by returning an empty body, letting the caller fall back to
the stored data instead of decoding a failure page.
The admin notification feed keeps its last-update timestamp in the same
storage, replacing its own copy of the file handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd -
Small fixes f7eef6
-
Single source of truth for is_outdated, is_installed 37ad01
-
Fixed `Undefined array key "type"` warning b2a9c1
-
Don't show non installed modules when asking for outdated bf5fb1
-
Version 1.12.27 ac70c7
-
Magento 2.4.9 fix (CLI execute command) d224be
-
Prevent news retrieval after each cache flush ac96b7
swissup/module-image-optimizer — 1.0.12 (was 1.0.11)
No commits found
swissup/module-theme-editor — 1.12.0 (was 1.11.6)
-
breezefront / breeze-enterprise
3.3.0 Aug-
3.3.0
Report (rev.1)
Package was generated and uploaded to GitHub on with the following dependencies:
swissup/module-ajaxsearch:1.14.11
swissup/module-breeze-ai:1.3.0
swissup/module-breeze-content-builder:1.2.0
swissup/module-breeze-content-builder-migration:1.0.1
swissup/module-breeze-layout-builder:1.2.0
swissup/module-easy-slide:1.8.18
swissup/module-easycatalogimg:1.5.15
swissup/module-navigationpro:1.19.3
swissup/module-testimonials:1.4.2
swissup/theme-frontend-breeze-enterprise-apollo:3.3.0
swissup/module-core:1.13.1
swissup/theme-frontend-breeze-enterprise-blank:3.3.0
Changelog (autogenerated)
swissup/module-ajaxsearch — 1.14.11 (was 1.14.10)
-
Version 1.14.11 0db900
-
Fixed layout shift when result list is long 8715cc
-
Fixed overflowed content in Firefox (Apollo theme) de660b
-
Merge pull request #57 from swissup/phpunit12-test-api-56
Migrate Test/Unit off removed PHPUnit APIs (PHPUnit 12 / PHP 8.5) 095093 -
test: migrate removed test APIs for PHPUnit 12 / PHP 8.5
The CI runner targets PHP 8.5, which requires PHPUnit 12.x. Migrate the
unit tests off APIs removed in PHPUnit 10-12:
- setMethods() (removed v10) -> onlyMethods(); all mocked methods verified
to exist on the target class via reflection. Dropped dead duplicate mock
builders (Magento CollectionFactory immediately overwritten by the
Swissup one).
- getMockForAbstractClass() (removed v12) -> createMock().
- will($this->returnValue()/returnSelf()/onConsecutiveCalls()) (removed
v12) -> willReturn()/willReturnSelf()/willReturnOnConsecutiveCalls().
- Non-static data providers (hard error v12) -> public static function.
Also fixed two removed APIs not listed in the issue but caught while
running the suite:
- $this->at() (removed v10) in DataTest and ProductTest -> willReturnMap()
/ atLeastOnce() / consecutive-call stubs.
InitTest was stale independently of PHPUnit: it exercised a removed
getClassNames() method and the old constructor/getSettings signature.
Realigned it to the current Init block and dropped the redundant
getClassNames test (already covered by Helper\DataTest).
Verified: 41 tests, 99 assertions, 0 errors, 0 deprecations on
PHPUnit 10.5 / PHP 8.3 (`bin/test`).
Refs swissup/module-ajaxsearch#56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 20854c -
test: add module-local PHPUnit runner
Add bin/test and phpunit.xml.dist so the unit suite can be run in
isolation via `bin/test` (runs vendor/bin/phpunit inside the container),
mirroring the setup already present in module-askit. .gitignore keeps the
PHPUnit result cache out of the repo.
Refs swissup/module-ajaxsearch#56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> a4e899
swissup/module-breeze-ai — 1.3.0 (was 1.0.12)
-
Version 1.3.0 050f99
-
Merge pull request #83 from breezefront/fix/ai-buttons-overlap-name-82
fix(admin): stop AI buttons overlapping long field values (#82) 3348b6 -
fix(admin): lay AI actions beside field, not over it (#82) 3eb55f
-
fix(admin): stop AI buttons overlapping long field values (#82) 1a4854
-
Merge pull request #81 from breezefront/feat/editor-chat-panel
feat(content-builder): chat panel in the editor for building and refining a page (#64) 0932fb -
fix(content-builder): give the message textarea an accessible name b48efb
-
fix(content-builder): ground every request in the live working copy
pageContext() only supplies the page's title and id, so a first chat
message had no representation of the page's actual content, and manual
edits made between AI replies were silently overwritten by the last
frozen AI document. Every request now seeds or refreshes its document
turn from contentBuilder.getComponents() instead. Also drops historical
image attachments from the request — maxBytes is sized for one
screenshot under post_max_size, and replaying every prior attachment on
each follow-up can push the aggregate body past that limit. 9ca7e3 -
fix(content-builder): drop a stale AI response after a page switch (#64) 371c91
-
refactor(content-builder): share the chat loop between the modal and the editor panel (#64)
Extracts turn storage, chat-bubble rendering, and copy-to-clipboard into
chat-core.js, used by both chat.js's field-targeted modal and
bcb-chat-panel.js's docked panel. The request/response cycle stays local
to each caller — they talk to different skills with different payload
and response shapes, and forcing that through one function would trade
duplication for a worse, branchier abstraction.
Fixes two bugs the duplication had introduced in bcb-chat-panel.js: the
Copy button was never bound (only chat.js's copy of the handler existed)
and a stale "type a message" error could survive a valid follow-up
submission. 1ab342 -
fix(content-builder): keep the generated document in follow-up history, guard double-submit a48469
-
feat(content-builder): chat panel in the editor for building and refining a page (#64)
Docks a conversational panel into the Content Builder editor's tree panel
via a mixin on bcb-panel.js. Every reply is applied straight into the
working copy through content-builder.js's applyAiComponents(), and every
request is sent persist:false so nothing reaches the draft until the
admin uses the editor's own Save/Publish. 9e66ae -
feat(content-builder): add non-persisting build endpoint and editor chat config (#64)
Adds options.persist=false to Controller/Adminhtml/Page/Build so a request
can return generated components without writing the Content Builder draft.
Extracts vision-model selection out of BuildFromScreenshot into a shared
VisionModelProvider, and adds Block/Adminhtml/Editor/AiChatConfig plus its
layout/template to seed the editor route with the same config. 6eca5e -
Merge pull request #80 from breezefront/claude/focused-payne-b60751
fix(content-builder): reproduce every band the screenshot shows (#74) a84559 -
fix(content-builder): word the band warnings for the request that was sent (#74)
Four defects found reviewing the review fixes.
The count-mismatch warnings hardcoded "The reference design was read as ...",
but the mismatch branch runs whether or not a screenshot was attached — the
prompt asks for a band count on every build, not only on the image path. A
text-only brief that came back over- or under-segmented therefore told the
merchant about a reference design they never sent, on the build note and in
the Content Builder editor. The flag that decides whether to warn now decides
what the warning calls the thing the page was counted against.
The band-wrapper warnings borrowed ComponentValidator's "root[N]" label while
numbering a different list. The validator indexes the raw model output, where
a dropped node still consumes its index; the audit sees the list after
validation. One dropped band was enough to put two warnings saying "root[0]"
in one list, pointing at two different bands. They are numbered as bands now.
readScreenshot() read and base64-encoded the file before ImageValidator got to
apply MAX_IMAGE_BYTES, so an oversized design export cost about 2.3x its size
in memory to reach a verdict available from filesize(). The admin path is
unaffected: the browser posts base64 and no file is read.
FiveBandMock::drawProductRow() took $bottom and never used it, so that band
alone would not follow a change to the band split in draw() — in the one file
whose docblock claims the band boundaries are readable in source. 685502 -
fix(content-builder): keep the band count when the reply carries prose (#74)
Three defects found reviewing the band-audit change.
The prompt says "no prose" and models add it anyway, which is why
jsonCandidates() exists at all. It tries the widest "[...]" run before
"{...}", correct while a bare list was the asked-for shape. With an envelope
whose "unsupported" is omitted and "components" last, that run is exactly the
components array: it parses, wins, and the band count is thrown away. The
audit then reported "the AI did not report how many bands it saw" for a reply
that did report it. A list is now held as a fallback and the remaining
candidates are still tried for an envelope, so a reply that really is a bare
list still resolves to the first list.
spreadStackedSlots() decided a band was stacked from the count of the first
slot alone. "children" written as a flat list of nodes rather than a list of
slots lands there with the node itself as the first slot, and its own keys are
counted as sibling blocks — {"type": ..., "props": ...} counts 2 in a
two-column band and was shredded across the slots, replacing the accurate
short-slot warning with a repair that never happened. Every entry now has to
look like a node.
render.php ignored the file_put_contents result and printed "(0 bytes)" with
status 0 on an unwritable path, sending whoever reproduces a measurement to
look at the build command instead. ee671e -
fix(content-builder): reproduce every band the screenshot shows (#74)
Shown a five-band reference design, gpt-4o-mini returned a document whose
columns bands were fakes: the container had the right type and the right
column count, but every card sat in its first slot, so the band rendered as
one column with the rest empty. Measured over ten runs of the committed mock,
that happened in nine of them.
Three changes, in order of how much they hold:
- ComponentValidator spreads a container whose whole band was written into
its first slot, when the arithmetic leaves no other reading. Prompting
against this only half worked; the repair is unambiguous.
- The prompt asks for {"bands", "unsupported", "components"} instead of a
bare array, spells out the slot shape, and names the columns-versus-
product_grid choice. A bare array is still accepted.
- BandAudit compares the band count the model reports to what it emitted and
reports the difference, plus any band the catalog cannot express. It
reports and does not retry: a second round trip would double the cost of
every build to re-roll an answer a small model is as likely to get wrong
again, and the missing band cannot be synthesised locally.
Measured with the mock and expected shape now under Test/Fixture, driven
through the new --screenshot option. Ten runs each, gpt-4o-mini:
before: 5 bands x5, 6 bands x4, 1 hard failure; 9/9 mis-slotted
after: 5 bands x10, 0 mis-slotted, 8/10 with both columns containers
gpt-4o gets the slots right with either prompt, so the mis-slotting was
model capacity and the prompt closes the gap on the small model. 0e5f35 -
Merge pull request #79 from breezefront/fix/build-note-page-id-type
fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7faf89 -
fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7f6aaa
-
Merge pull request #78 from breezefront/claude/priceless-turing-a344ac
fix(config): offer model names the providers still serve (#73) 21eee6 -
Merge pull request #77 from breezefront/claude/jovial-chatelet-3447d7
feat(content-builder): report what the validator repaired (#72) d23f45 -
feat(content-builder): report what the validator repaired (#72) 4b47e4
-
fix(content-builder): drop a corrupt build note instead of reporting a clean build (#72) e3994a
-
fix(gemini): redact api key from error, notice for retired models (#73) 922d51
-
fix(config): offer model names the providers still serve (#73) b1963c
-
Merge pull request #76 from breezefront/claude/lucid-kepler-a7ce73
fix(content-builder): keep authoring placeholders out of generated copy (#71) 6a5b87 -
fix(content-builder): keep required sample copy on the malformed-value path (#71)
fallbackValue() blanked any authoring placeholder, so a required copy prop the
model sent as an array — quote.text, in the fixture — came out empty, which is
the broken component the omitted-prop path deliberately avoids. The rule now
lives in one predicate used by both paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8a6461 -
fix(content-builder): keep authoring placeholders out of generated copy (#71)
The catalog's "default" attribute does two jobs: the value a renderer needs
when a prop is unset, and the sample text the editor drops into a fresh
component. The validator filled both, so "Discover Something New" shipped as
the heading of three FAQ answers. Copy-typed props whose default reads as
prose are now stored blank — blank, not absent, because the templates read
`$props['heading'] ?? '<sample>'` — and the prompt marks them VISIBLE-TEXT
instead of quoting the sample text that taught the model to echo it.
A list-valued prop is blanked as an empty list, so an unusable faq_items no
longer falls back to the two sample questions. Tabs keeps its declared titles:
the container counts its slots from them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 00e837 -
Merge pull request #75 from breezefront/claude/amazing-lichterman-a86181
fix(content-builder): keep the list props the model wrote (#70) 819b38 -
fix(content-builder): keep every tab title paired with its slot (#70)
Dropping an unusable title compacted the list while normalizeChildren()
truncates slots from the end, so ["Shipping", "", "Returns"] over three
populated slots rendered "Returns" above the middle slot's content and threw
the last one away.
A list of strings is positional — its container pairs slot N with entry N —
so an unusable entry is now replaced where it stands, taking the declared
default's title for that position ("Tab 2"), which is what the container's
renderer pads a short title list with anyway. Entries that carry their own
content, like the FAQ items, have no slot to stay paired with and are still
dropped.
An empty list now says so rather than reporting that none of its zero
entries was usable. efb774 -
fix(content-builder): read a keyed object as one list entry (#70)
decodeList() accepted any array, so a keyed object counted as a list: an FAQ
item the model sent unwrapped — {"q": ..., "a": ...} instead of [{...}] —
was iterated field by field, turning the question and the answer into two
bogus items, each with the other's text as its question.
Only a real list is a list now. A keyed object carrying at least one declared
field is wrapped as the single entry it is, so the question the model wrote
survives; one carrying none falls back to the default with a warning naming
the fields that were expected. 71d179 -
fix(content-builder): keep the list props the model wrote (#70)
The catalog asks the model for the Tabs titles and the FAQ items as JSON
arrays, then ComponentValidator coerced every non-scalar prop back to its
default — so a generated FAQ arrived with its questions replaced by "Tab 1"
/ "Tab 2" / "Tab 3", and the Tabs slot count fell back to the default three,
dropping any further answer with it.
A prop whose declared default is a JSON array is now validated as a list
instead of discarded: entries are cleaned one by one and written back in the
JSON-string shape the editor stores and the renderers decode, so the slot
count follows the titles the model actually wrote. The default is only
reached when nothing usable is left, and the warning then names the reason.
Recognised by the shape of the declared default rather than by prop name, so
the FAQ accordion's question/answer items are covered by the same path. a4765b -
Merge pull request #69 from breezefront/claude/stoic-yonath-58797a
fix(content-builder): drop fake "spacing" prop from catalog prompt 7db871 -
fix(content-builder): drop fake "spacing" prop from catalog prompt
The margin/padding summary line started with the group label instead of
prop names, so the model read "spacing" as a prop and emitted it on every
component that had one. Render one line per value range, leading with the
real names, which also stops claiming -200 as the minimum for paddings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> beb335 -
chore: drop stray .b64 scratch files from screenshots dir
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> e5e228 -
docs: add screenshots to test guide, correct scenarios after live testing
Vision-model dropdown pre-filters server-side (10.6 unreachable via UI as
written), Remove+re-attach confirmed clean (10.9). Found and filed a real
draft-persistence bug in module-breeze-content-builder (#31) — re-run builds
report success but don't survive a fresh page reload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8062ec -
docs: add test scenarios and how-to-test guide for Content Builder screenshot import
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8e6e8d -
Merge pull request #67 from breezefront/feat/value-backup
feat(backup): record values before AI writes 70f7cc -
fix(backup): skip ambiguous staging rows 27e298
-
fix(backup): guard capture reads, batch pruning 19f666
-
feat(backup): record values before AI writes 21d8f6
-
Merge pull request #66 from breezefront/test/bulk-selection-regression
test(bulk): pin the scope a run resolves to b69352 -
test(bulk): seed the products the scope cases run against
Copilot was right on both counts: the cases borrowed whatever the
catalogue happened to hold, and the exclusion case skipped itself whenever
no two SKUs shared a five-character prefix. A regression check that goes
quiet when the surrounding data does not suit it is not a guard.
Three products with a prefix unique to the run are created in setUp and
removed in tearDown, so every case is deterministic and none can skip. The
prefix also gets its own case, since the two filtered cases rest on it
selecting this test's products and nothing else.
Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour now turns five
of the six red, up from four. The unfiltered select-all stays green, which
is correct - that path is the legitimate one and behaved the same before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e82c74 -
test(bulk): pin the scope a run resolves to
The selection fault has landed three times. 15c1e7c introduced an
all_selected flag driven by allSelected(), #31 closed it in June, and
c13907e swapped in excludeMode() and brought it straight back - Magento
raises either flag by itself once the checked rows cover the rows the grid
is showing. Both attempts only ever argued about the client-side flag,
while the resolver went on loading the collection with no filters at all.
The E2E spec covers the request the wizard posts. Nothing covered what
that request then resolves to, which is the number that decides how many
products get rewritten. These cases do, against the live catalogue: an
explicit selection, a genuine select-all, a select-all narrowed by a
filter, exclusions inside a filtered set, and an empty selection.
Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour turns four of
the five red, the exception being the unfiltered select-all - which is
correct, since that case is the legitimate path and behaved the same
before.
Refs #61
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8e8b71 -
Version 1.2.0 634a46
-
Merge pull request #63 from breezefront/feat/build-page-from-screenshot
Build a Content Builder page from a screenshot 680ede -
fix(vision): close the second round of review points on #63
The one that mattered: `options['brief']` skipped the fix from the previous
commit entirely. It leaves the history untouched by design, so an
alternating conversation that ended on a user turn got our prompt appended
after it — the exact pair Anthropic answers 400 to, reachable from REST
where the caller supplies both a brief and a history. The prompt is now
folded into a trailing user turn instead of following it, which keeps that
turn's words rather than dropping them. The class docblock claimed "a
history that already alternates keeps alternating"; it does now.
`testSendsTheHistoryFollowedByTheGeneratedPrompt` was pinning the broken
shape — two user turns, asserted as correct — so its history now ends on
an assistant turn and the folding case has tests of its own.
The block hid itself for every reason a build could fail except the two
the controller actually enforces. A role holding `Magento_Cms::page` but
not `Swissup_BreezeAi::index` or `Magento_Cms::save` saw a working-looking
button whose every click came back as the login page, which the modal can
only report as an expired session.
Also from the review: the modal kept the previous brief when reopened, so
it would have ridden the next screenshot unseen; an in-flight FileReader
could re-arm a screenshot the admin had already removed, now invalidated
by a token that `clearScreenshot()` moves on; the drop zone gained
`role="button"` and the brief and model controls gained real labels, since
a placeholder is not one and the select had no accessible name at all.
A warning-free build no longer redirects on its own. The draft is saved by
then but the CMS form behind the modal is not — this request never touched
it — so a title or URL key typed before opening the modal was silently
lost. The editor is offered as a link in both outcomes.
635 unit, 18 integration, 30 e2e green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3072d9 -
fix(vision): answer the three points Copilot raised on #63
The docs described a cap the code stopped using. `BuildFromScreenshot`
reads `post_max_size`, and its own comment explains why `getMaxFileSize()`
is the wrong ceiling for a base64 string in a text field — the doc still
named `getMaxFileSize()`, which is exactly the sort of mismatch someone
debugging a 413 would be misled by.
The drop zone carries `tabindex="0"` but only answered to a click, so it
could be tabbed to and not opened. Enter and Space now trigger the picker,
with the same guard the click handler needs: the remove control sits
inside the zone and answers to both keys, and swallowing them there would
leave it dead to the keyboard.
`getVisionModels()` is asked for four times while one CMS page form
renders — `canShow`, `getConfigJson`, and `getSelectedModelId` from inside
it — and each ask loads every model to read its metadata. Memoized for the
life of the block, which is one request.
631 tests green; 29 e2e, the new keyboard case among them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2e6454 -
test(e2e): assert the selection contract the wizard actually posts
33d5e97 replaced the all_selected flag with Magento's own mass-action
contract — one of selected or excluded, plus the grid namespace and the
filters that were on screen — and updated SaveTest, but the Playwright spec
kept asserting the flag that no longer exists. Red on master since then.
The assertion now names what the fix was for: the filters travel with the
selection, which is what stops an exclude-mode Select All from widening past
the rows the grid was showing. f34845 -
feat(vision): build a Content Builder page from a screenshot
PR #55 landed the whole image path — MessageInterface::getImages(),
ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
nothing used it. Every caller of build_page passed an empty history, and no
UI produced an images list. This gives the admin a way in.
A button in the Content Builder fieldset of the CMS page form opens a modal:
attach a screenshot, optionally type a brief, pick a vision-capable model.
The file is read with FileReader and posted as a data URI inside
history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
runs the skill, saves the result through DraftWriter, and answers with the URL
of the editor the admin reviews it in.
Synchronous rather than an option on the bulk wizard: a reference screenshot
is a per-page input, and one screenshot over N pages produces N near-identical
layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
all three providers are used exactly as PR #55 built them, unchanged.
BuildPage now lifts images off the user turns they arrived on and puts them on
the prompt turn it builds, consuming the last user turn as the brief. That
fixes an existing fault: Model\Provider\Claude passes the message list through
unmerged and Anthropic refuses two user turns in a row, so the old
array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
fallback was the thing being used.
PromptBuilder gains a conditional Reference design block. Two of its five
rules carry the weight: without the band-per-row line the model answers with a
flat component list and the row-wrapping rule loses to the picture, and
without the ignore-brand-and-colours line it copies the screenshot's hex
values into every colour prop and the draft clashes with the merchant's theme.
Verified end to end against gpt-4o-mini on a real storefront screenshot:
six row-wrapped bands, no validator repairs, draft readable in the editor.
With a brief added, the structure holds and the copy follows the brief rather
than transcribing the screenshot.
540 -> 621 unit tests, all green. 9d8854 -
test(mcp): list build_page among the tools the server exposes
McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
with four tools. build_page joined the skill pool in 1bf01f6, and every skill
in the pool is exposed as a tool, so the integration suite has been red since
then. Nothing about the server changed — only what the test expects. f8588c -
test(e2e): cover the build-from-screenshot modal
The one surface unit tests cannot reach. It exists because of a bug that got
all the way to review: the file input is a child of the drop zone, so the
synthetic click jQuery sends it bubbles back into the zone's own handler and
recurses until the stack blows — the picker never opens and only drag and drop
works. "clicking the drop zone opens the file picker" is the guard for that.
Also covers the pre-flight refusal of a non-image, that the refusal sends no
request, the preview and remove cycle, and that a reopened modal carries none
of the previous run's state.
Stops short of pressing Build draft: that would spend a real provider call.
Skips itself where the control legitimately hides — no Content Builder, or no
configured model that reads images. 10934b -
test(mcp): list build_page among the tools the server exposes
McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
with four tools. build_page joined the skill pool in 1bf01f6, and every skill
in the pool is exposed as a tool, so the integration suite has been red since
then. Nothing about the server changed — only what the test expects. 60d8c7 -
feat(vision): build a Content Builder page from a screenshot
PR #55 landed the whole image path — MessageInterface::getImages(),
ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
nothing used it. Every caller of build_page passed an empty history, and no
UI produced an images list. This gives the admin a way in.
A button in the Content Builder fieldset of the CMS page form opens a modal:
attach a screenshot, optionally type a brief, pick a vision-capable model.
The file is read with FileReader and posted as a data URI inside
history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
runs the skill, saves the result through DraftWriter, and answers with the URL
of the editor the admin reviews it in.
Synchronous rather than an option on the bulk wizard: a reference screenshot
is a per-page input, and one screenshot over N pages produces N near-identical
layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
all three providers are used exactly as PR #55 built them, unchanged.
BuildPage now lifts images off the user turns they arrived on and puts them on
the prompt turn it builds, consuming the last user turn as the brief. That
fixes an existing fault: Model\Provider\Claude passes the message list through
unmerged and Anthropic refuses two user turns in a row, so the old
array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
fallback was the thing being used.
PromptBuilder gains a conditional Reference design block. Two of its five
rules carry the weight: without the band-per-row line the model answers with a
flat component list and the row-wrapping rule loses to the picture, and
without the ignore-brand-and-colours line it copies the screenshot's hex
values into every colour prop and the draft clashes with the merchant's theme.
Verified end to end against gpt-4o-mini on a real storefront screenshot:
six row-wrapped bands, no validator repairs, draft readable in the editor.
With a brief added, the structure holds and the copy follows the brief rather
than transcribing the screenshot.
540 -> 621 unit tests, all green. c53c71 -
Version 1.1.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3bc1cd -
Merge pull request #60 from breezefront/fix/bulk-selection-scope
fix(bulk): scope a run to what the grid was actually showing 49b5f5 -
fix(bulk): scope a run to what the grid was actually showing
A client selected one product and the wizard queued a run over all 714.
The submit path decided "everything is selected" from the grid
excludeMode flag, and core turns that flag on by itself as soon as the
checked rows cover the rows the grid currently holds - filtering down to
a single product and ticking it is indistinguishable from pressing Select
All. The server then made it worse: the all_selected branch loaded the
entity collection with no filters at all, so even a deliberate select-all
meant the whole catalogue rather than the filtered set. Either fault
alone produces the wrong count.
Both go away by speaking Magento's own mass-action contract. The wizard
now posts selected or excluded alongside the grid namespace, filters and
search, exactly as Magento_Ui/js/grid/massactions does, and
BulkEntityIdResolver runs the collection through MassAction\Filter. An
exclude-mode selection resolves against the filters that were on screen,
so the accidental flag can no longer widen the scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 33d5e9 -
Version 1.1.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f29385 -
Merge pull request #59 from breezefront/fix/model-form-provider-options
fix(admin): serve the model form's options from the option sources 72aff2 -
fix(admin): serve the model form's options from the option sources
The model select is repopulated in the browser whenever the provider
changes, and the map it read from was a hand-maintained copy inside
swissup_breezeai_model_form.xml. Adding GPT-5.6 updated OpenAiModel and
the switcher rules but not that copy, so Sol, Terra and Luna could not be
picked at all - and the run wizard's gpt-5.6-terra default could never
match a saved row.
ProviderModels builds the map from the same option sources the form
already declares, injected through FormDataProvider::getMeta(). One list
per provider, and registering a provider in di.xml is enough for its
models to appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 936696 -
Merge pull request #58 from breezefront/fix/temperature-zero
Stop dropping a temperature of 0 d79542 -
fix(providers): stop dropping a temperature of 0
Claude and Gemini read the value with an assignment inside the condition:
if ($temperature = $config->getExtra('temperature')) {
which is a truthiness test, and `(bool) 0` is false. An admin setting 0 — the
value you pick precisely to make output deterministic — had it silently discarded
and got the provider's own default instead, 1.0 on Claude. No error, no warning,
just different output than asked for.
Compared against null and '' now, so 0 reaches the request and an unset field
still sends nothing. OpenAI was already correct via `?? DEFAULT_TEMPERATURE`.
Found by exercising the admin form rather than by reading: the field only became
settable when the extra_config registry landed, and both existing provider tests
covered temperature at 0.5 and 0.7, never at 0. Tests now cover 0 and unset for
both providers, and fail on the old code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> da9be6 -
Merge pull request #57 from breezefront/refactor/extra-config-registry
One registry for the extra_config fields 40cddc -
Merge pull request #55 from breezefront/feat/vision-image-input
Vision foundation: let a message carry images, encode them per provider fd09a0 -
refactor(config): one registry for the extra_config fields
Which fields live in the extra_config JSON was written down three times: in the
save controller, in the form data provider, and in the form XML. Nothing failed
when they disagreed — a field missing from the save list is silently not stored,
one missing from the read list comes back blank on every edit. Adding `vision`
meant three coordinated edits with no test that would catch a missed one.
`temperature` is what that cost. All three providers read it; neither list
carried it and the form had no field, so it could not be set from the admin at
all. It has a field now, and the notice says what the ranges are and that GPT-5
and the o-series ignore it.
Model/ModelConfig/ExtraFields owns the keys and how each normalises. Types
matter here: a blank submission clears the key, an int at zero clears it — no
token cap means anything at zero — but a float at zero is kept, because zero is
a meaningful temperature and dropping it would silently restore the provider
default for someone asking for deterministic output.
The form XML still declares its own fields: a field needs a label, a notice and a
control that no list can supply. What moved is which keys travel in and out.
Needs setup:di:compile — both consumers gained a constructor argument.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1dc4b -
refactor(vision): cut ImageBuilder to the shape callers send
Five accepted spellings of the same input for one call site: fromBinary had no
caller at all, and fromArray took `data_uri` beside `url`, `mimeType` beside
`mime_type`, and a data URI hidden in `data`. Each was a guess about a caller
that does not exist — no skill attaches an image yet, and no JS sends one.
What is left is what GenerateService passes: `{mime_type, data}`, or a data URI
under `url` — the key OpenAI's own image_url part uses, so it is the one a caller
already has a name for. fromDataUri and the base64 check became private; the
public surface is fromArray, which is the only method anything calls.
94 lines to 90, and 4 public methods to 1. Adding a shape back is cheap once a
caller wants it; carrying four unused ones is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 66ac12 -
fix(vision): address the review findings on this PR
VisionSupport keeps only the `vision` config key and reads ModelMetadata for
what the name means, so its two prefix tables and its copy of matchesPrefix are
gone — 116 lines to 70. The o-series can no longer be listed here as text-only
while being listed there as reasoning.
ImageValidator collects every image, checks the count, and only then decodes.
The cap was the last rule tested, so an over-cap request paid for N base64
decodes to be refused, and was refused for whichever image happened to be
malformed first rather than for being over cap.
ImageBuilder accepts and discards parameters between the mime type and
`;base64`. A `charset` an exporter felt like adding says nothing about image
bytes, and refusing the URI over it refused a readable image.
GenerateService::toMessage() no longer claims Web API hands history over as
arrays. It does not: /V1/breezeai/generate declares MessageInterface[] and
ServiceInputProcessor dies with "Cannot instantiate interface" before the service
runs, because the interface has no DI preference. The caller that really passes
arrays is the admin controller, which decodes the body itself. History over REST
predates image input and is not fixed here; docs/DEVELOPMENT.md says so instead
of implying the route works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> dd6371 -
feat(providers): let a message carry images, and encode them per provider
A message gains getImages(): ImageInterface[] alongside its string content,
and each provider encodes those images into its own wire format — an
Anthropic base64 image block, an OpenAI image_url part holding a data URI,
a Gemini inline_data part. Content stays a string, so a text-only turn
produces the same request body it did before and every existing caller of
`new Message($role, $content)` keeps working.
Two things guard the send. ImageValidator enforces the limits the three
providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
request, user turns only — and VisionSupport answers whether the model a
call is routed to reads images at all, from the model name plus a `vision`
override on the model row (the new Image Input field). AiRequest runs both
before reaching the provider, so an image sent to a text-only model fails
with one sentence naming the model rather than a provider 400. Callers that
want to know in advance ask AiRequest::supportsVision().
ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
a decoded request payload — into an ImageInterface, and GenerateService
uses it so history posted over the wire can carry images too.
No skill attaches an image yet; this is the layer under that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ad9747 -
fix(data): document every Message constructor parameter
Magento reads @param tags positionally — TypeProcessor::getParamDocBlockTag()
indexes the tag list with the parameter's own offset — so a block documenting
only $images typed $role from the images tag and had no tag to return for
position 2. Reflecting the constructor to build a Message from request data
died there with a TypeError before any of our own validation ran.
The two missing tags are the whole fix. A test pins the invariant, since the
next parameter added to this constructor would silently reintroduce it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b793a0 -
Merge pull request #56 from breezefront/refactor/model-metadata
One table for what a model name means eb79b5 -
fix(model): correct the model facts the merge exposed
Putting both axes in one table made three disagreements visible:
o1 reads images. It was absent from the image-input list while present in the
parameter-set one, which is the drift this table exists to stop. o1-mini and
o1-preview genuinely do not, so they say so as longer prefixes.
gpt-4-vision-preview — where vision on GPT-4 started — matched nothing and read
as text-only, because the list carried gpt-4-turbo and gpt-4o but not the model
named for the capability.
claude-4 was dead weight: Claude 4 and later put the tier first, as in
claude-opus-4-5, and those already match claude-opus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f113ee -
refactor(model): read model facts from one table
RequestBody carried three prefix lists and VisionSupport (in #55) carried two
more, each with its own copy of matchesPrefix. Nothing tied them together, so
they drifted: the parameter-set list knew about o1 and the image-input list did
not, and no test could catch it because neither list knew the other existed.
ModelMetadata holds the facts once, keyed by provider and name prefix. Every
prefix a name starts with contributes, shortest first, so a longer prefix
overrides a shorter one and states only what differs — o3-mini says it takes no
image and inherits its parameter set from o3. That ordering is what removes the
separate lists of exceptions each consumer used to keep beside its main list.
A per-row sampling or vision value stays with the consumer that reads it: the
row overriding the name is config, not a fact about the model.
Behaviour is unchanged — every existing RequestBody test passes untouched, and
the table reproduces the old lists name for name, o1's missing image support
included. Correcting that is the next commit, kept separate so this one reads
as the move it is.
supportsVision() has no caller on master yet; #55 is the caller and rebases
onto this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 891642 -
feat(providers): let a message carry images, and encode them per provider
A message gains getImages(): ImageInterface[] alongside its string content,
and each provider encodes those images into its own wire format — an
Anthropic base64 image block, an OpenAI image_url part holding a data URI,
a Gemini inline_data part. Content stays a string, so a text-only turn
produces the same request body it did before and every existing caller of
`new Message($role, $content)` keeps working.
Two things guard the send. ImageValidator enforces the limits the three
providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
request, user turns only — and VisionSupport answers whether the model a
call is routed to reads images at all, from the model name plus a `vision`
override on the model row (the new Image Input field). AiRequest runs both
before reaching the provider, so an image sent to a text-only model fails
with one sentence naming the model rather than a provider 400. Callers that
want to know in advance ask AiRequest::supportsVision().
ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
a decoded request payload — into an ImageInterface, and GenerateService
uses it so history posted over the wire can carry images too.
No skill attaches an image yet; this is the layer under that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 659e74 -
Version 1.1.0 9aeb4e
-
Merge pull request #54 from breezefront/feat/bulk-run-model-and-system-prompt
feat(bulk): pick the model and the framing per run, and pin them to it cab8ed -
fix(bulk): keep the closing script tag out of a JS comment
The comment warning about a prompt body holding a closing script tag
contained one, which ended the block early and dumped the rest of the
script onto the page as text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1f118 -
feat(bulk): pick the model and the framing per run, and pin them to it
The bulk wizard ran on whatever the configuration said at the moment each
operation was picked up, with no way to steer the model or tell it what
kind of writing was wanted. The client rated the German output 4/10 and
asked for both: a model per run, and a free-text instruction that reaches
the model as framing rather than as part of the product text.
Two things had to be true for that to mean anything.
The framing has to arrive in the system channel, ahead of the content.
It does: the skills put it in a system message, and the providers already
agree that a caller's system message wins over their built-in default.
Where a run brief is present the translate prompt drops its own "you are
a professional e-commerce translator" opening — that line answers the one
question the brief exists to answer — while keeping the mechanical rules
about numbers, SKUs and HTML, which are output format rather than
persona.
And both have to hold still. A batch of a few thousand products drains
over hours, so resolving configuration per operation means editing the
default model halfway through silently splits the batch across two
models with nothing in the result to say which got which. The model and
the prompt are now decided when the run is scheduled and travel in the
operation payload, which is already immutable once written — no queue
schema change. Even leaving the dropdown alone pins a concrete row:
"the default model" is a moving target.
The model ID is a pointer and the row behind it stays editable, so the
provider and model name ride along as a snapshot and the consumer stops
rather than bill a model nobody chose. The API key is deliberately not
snapshotted — rotating one has to reach the runs already queued.
For Generate this reverses an existing precedence: a model passed in now
beats the one bound to the prompt. Step 5 tells the operator which model
the run will use, and a per-prompt model overriding that would make the
screen lie. Nothing outside the wizard passes one, so the prompt's own
model still decides everywhere else.
Two small stores back the UI. Prompts gain a type, so a run brief can be
saved in the existing library without turning up in the per-field
dropdowns; rows written before the column are field prompts, which is all
the module could store then. And one row per admin remembers what the
wizard was last run with, so it opens on that instead of an empty form —
a preference, not a reference, which is why it carries no foreign key to
the model table: a model since deleted is simply not offered.
Operations queued before any of this carry none of the new keys and keep
running on the default model.
setup:upgrade applies the schema; Claude, Gemini and the bulk consumer
take new constructor arguments, so setup:di:compile is needed as well.
./bin/test unit — OK (454 tests, 901 assertions)
phpcs --standard=Magento2 — 0 errors on every touched file
Schema verified against a real database, not only declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> fcb9d8 -
fix(providers): agree on what a caller's system message means (#53)
* fix(providers): agree on what a caller's system message means
The three providers disagreed. OpenAI lets a caller-supplied system message
replace the built-in framing; Claude and Gemini filtered it out of the
conversation and then sent their own constant anyway, so the instruction was
read, dropped, and never reached the model. The same prompt therefore meant
different things depending on which model it happened to be assigned to — and
silently, because nothing errors when a system message is discarded.
Nothing sends one today, so this fixes no live failure. It removes a trap: the
first skill that reaches for its own framing would have got it on OpenAI and
lost it on the other two, and the symptom is bad output rather than an error.
`Model\Provider\SystemMessage` now resolves the framing for all three — the
caller's, or the built-in default when the caller supplied none. Where it goes
still differs per API: inline in `messages` for OpenAI, the top-level `system`
field for Claude, `system_instruction` for Gemini. More than one system message
is joined in order rather than reduced to the first, since Claude and Gemini
have a single field for it and dropping the rest would lose instructions.
The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
points at the shared constant so it keeps working for anything referencing it.
Claude and Gemini take a new constructor argument, so this needs
`setup:di:compile`.
./bin/test unit — OK (409 tests, 810 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(openai): resolve the framing through the shared rule, not a copy of it
The previous commit gave Claude and Gemini a shared SystemMessage but left
OpenAI deciding for itself in buildMessages(). Only the default string was
shared, so the two paths could still drift: several system messages were
joined on Claude and Gemini and left scattered through the conversation on
OpenAI, and any later change to resolve() would have reached two providers
out of three.
OpenAI now calls resolve() as well. The framing still travels as the first
entry of `messages` because that is what the endpoint takes; the caller's
system messages are lifted out of the conversation and folded into it, so
the instruction is sent once rather than twice.
resolve() also stops treating a blank system message as one supplied. An
empty string is not framing, and passing it through meant Gemini receiving
an empty `system_instruction` part — which it refuses — where before the
constant always went out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 2c716b -
fix(openai): resolve the framing through the shared rule, not a copy of it
The previous commit gave Claude and Gemini a shared SystemMessage but left
OpenAI deciding for itself in buildMessages(). Only the default string was
shared, so the two paths could still drift: several system messages were
joined on Claude and Gemini and left scattered through the conversation on
OpenAI, and any later change to resolve() would have reached two providers
out of three.
OpenAI now calls resolve() as well. The framing still travels as the first
entry of `messages` because that is what the endpoint takes; the caller's
system messages are lifted out of the conversation and folded into it, so
the instruction is sent once rather than twice.
resolve() also stops treating a blank system message as one supplied. An
empty string is not framing, and passing it through meant Gemini receiving
an empty `system_instruction` part — which it refuses — where before the
constant always went out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7ddc5f -
fix(providers): agree on what a caller's system message means
The three providers disagreed. OpenAI lets a caller-supplied system message
replace the built-in framing; Claude and Gemini filtered it out of the
conversation and then sent their own constant anyway, so the instruction was
read, dropped, and never reached the model. The same prompt therefore meant
different things depending on which model it happened to be assigned to — and
silently, because nothing errors when a system message is discarded.
Nothing sends one today, so this fixes no live failure. It removes a trap: the
first skill that reaches for its own framing would have got it on OpenAI and
lost it on the other two, and the symptom is bad output rather than an error.
`Model\Provider\SystemMessage` now resolves the framing for all three — the
caller's, or the built-in default when the caller supplied none. Where it goes
still differs per API: inline in `messages` for OpenAI, the top-level `system`
field for Claude, `system_instruction` for Gemini. More than one system message
is joined in order rather than reduced to the first, since Claude and Gemini
have a single field for it and dropping the rest would lose instructions.
The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
points at the shared constant so it keeps working for anything referencing it.
Claude and Gemini take a new constructor argument, so this needs
`setup:di:compile`.
./bin/test unit — OK (409 tests, 810 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 435eb3 -
fix(openai): send the parameter set the model family accepts (#52)
* fix(openai): send the parameter set the model family accepts
Every request carried temperature, frequency_penalty and presence_penalty,
and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
family and the o-series refuse all four on the same endpoint: the two
penalties are rejected outright even at 0, temperature takes nothing but
its default, and the token cap was renamed to max_completion_tokens. A
request built the old way comes back 400, so those models did not degrade
on this provider — they failed every call.
Nobody hit it because the model dropdown stopped at gpt-4o-mini and
reaching a newer one meant typing it into the custom field. The client on
the translation project asked for gpt-5.6-terra, which is where this
starts to matter.
The body is now built by a small class of its own. The provider builds its
HTTP client inline and cannot be exercised without a network, which left
the part that has to be right per model family with no test at all; that
part is now pure and covered.
Family is decided by the model name, with a sampling extra-config key to
override it: an Azure deployment or a proxy can serve GPT-5 under a name
that says nothing, and prefix matching would send it what it refuses.
reasoning_effort and verbosity are passed through where they are
understood, so the effort knob is reachable without another release.
A caller-supplied system message now replaces the built-in one instead of
being appended after it — two system messages in one request left the
model to pick. No caller sends one yet.
The three GPT-5.6 tiers are in the model dropdown, so the client's model
is a selection rather than a hand-typed string.
phpcs --standard=Magento2 reports 0 errors on the touched files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(openai): gate the reasoning knobs per family and expose them in admin
The first pass treated GPT-5 and the o-series as one family. They are not:
`verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
rejects `reasoning_effort` as well. Sending either to a model that refuses it
reproduces the 400 this branch set out to remove, so the knobs are now offered
per family while the token-cap rename and the dropped sampling parameters stay
common to both.
The knobs themselves had no way in. `sampling`, `reasoning_effort` and
`verbosity` were readable only by hand-editing the extra_config JSON, which
made the escape hatch for unnamed Azure and proxy deployments unreachable in
practice. All three are now fields on the model form, saved into and read back
out of extra_config alongside max_tokens.
Also:
- honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
matching what the Claude and Gemini providers already do
- read `sampling` without casting, so a non-string value in the JSON cannot
raise an array-to-string conversion
- note in the class docblock that an unrecognised model name falls back to the
GPT-4 parameter set, so the next family will need its prefix added here
./bin/test unit — OK (401 tests, 802 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 37217f -
fix(openai): gate the reasoning knobs per family and expose them in admin
The first pass treated GPT-5 and the o-series as one family. They are not:
`verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
rejects `reasoning_effort` as well. Sending either to a model that refuses it
reproduces the 400 this branch set out to remove, so the knobs are now offered
per family while the token-cap rename and the dropped sampling parameters stay
common to both.
The knobs themselves had no way in. `sampling`, `reasoning_effort` and
`verbosity` were readable only by hand-editing the extra_config JSON, which
made the escape hatch for unnamed Azure and proxy deployments unreachable in
practice. All three are now fields on the model form, saved into and read back
out of extra_config alongside max_tokens.
Also:
- honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
matching what the Claude and Gemini providers already do
- read `sampling` without casting, so a non-string value in the JSON cannot
raise an array-to-string conversion
- note in the class docblock that an unrecognised model name falls back to the
GPT-4 parameter set, so the next family will need its prefix added here
./bin/test unit — OK (401 tests, 802 assertions)
phpcs --standard=Magento2 — 0 errors on all touched files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7d1b03 -
fix(openai): send the parameter set the model family accepts
Every request carried temperature, frequency_penalty and presence_penalty,
and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
family and the o-series refuse all four on the same endpoint: the two
penalties are rejected outright even at 0, temperature takes nothing but
its default, and the token cap was renamed to max_completion_tokens. A
request built the old way comes back 400, so those models did not degrade
on this provider — they failed every call.
Nobody hit it because the model dropdown stopped at gpt-4o-mini and
reaching a newer one meant typing it into the custom field. The client on
the translation project asked for gpt-5.6-terra, which is where this
starts to matter.
The body is now built by a small class of its own. The provider builds its
HTTP client inline and cannot be exercised without a network, which left
the part that has to be right per model family with no test at all; that
part is now pure and covered.
Family is decided by the model name, with a sampling extra-config key to
override it: an Azure deployment or a proxy can serve GPT-5 under a name
that says nothing, and prefix matching would send it what it refuses.
reasoning_effort and verbosity are passed through where they are
understood, so the effort knob is reachable without another release.
A caller-supplied system message now replaces the built-in one instead of
being appended after it — two system messages in one request left the
model to pick. No caller sends one yet.
The three GPT-5.6 tiers are in the model dropdown, so the client's model
is a selection rather than a hand-typed string.
phpcs --standard=Magento2 reports 0 errors on the touched files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3b7370 -
Merge pull request #51 from breezefront/fix/category-translate-source-language
fix(translate): name the source language when translating categories db336b -
docs(translate): tighten the source-store comment
The docblock read like a changelog entry for 1.0.11. Keep the reason the
source store is handed to the skill, drop the history around it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f63c1b -
fix(translate): name the source language when translating categories
1.0.11 changed the translate prompt to name both languages ("translate
from Danish to German") because a weak model, told only the target, left
a good part of the text in the source language. The bulk consumer was
only half-converted: translateProducts() passes source_store_id, but
translateCategories() still calls the skill with the attribute and the
target store alone.
The source store was already used to load the category, so the values fed
to the model were right — only the prompt was missing the "from" half.
Category translations therefore kept running on the pre-1.0.11 prompt and
came back partly untranslated, the exact symptom 1.0.11 was released to
cure. Nothing surfaced it: the skill treats source_store_id as optional
and silently omits the clause when it is absent.
The unit test now asserts the whole options array rather than accepting
any call, so the next field added to it cannot go missing the same way.
phpcs --standard=Magento2 reports 0 errors on the touched files, the same
as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6871f1 -
Merge pull request #50 from breezefront/fix/bulk-empty-field-panels-v2
fix(bulk): do not offer a task whose field panel has nothing in it acd541 -
fix(bulk): do not offer a task whose field panel has nothing in it
The wizard decides what step 2 may offer by asking whether a step 4 panel
exists for the pair. A panel could exist and still be empty: Generate
renders a row per attribute only when a prompt is bound to it, so with no
prompts configured the panel is a bare fieldset. The check said yes, the
operator picked Generate, and step 4 came up blank with a Run button that
never enabled — the dead end the panel check was meant to close, reached
from inside the panel instead of around it.
Prompts are now resolved once up front and an attribute without any is
dropped, which makes "the panel has fields" knowable before rendering.
A panel with no fields is not rendered, so the existing check reports the
truth for it. Translate gets the same treatment for symmetry, though its
field list is only empty on entity types nobody can select today.
That leaves step 2 able to come up with nothing at all in it, so it now
says why instead of showing an empty box.
phpcs --standard=Magento2 reports 0 errors on the touched file, the same
as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 392330 -
Merge pull request #48 from breezefront/fix/bulk-save-skill-entity-validation
fix(bulk): refuse a skill the queue cannot run on the chosen entity type 24f2e1 -
Merge pull request #49 from breezefront/fix/bulk-empty-field-panels
fix(bulk): do not offer a task whose field panel has nothing in it ecfa84 -
Merge pull request #46 from breezefront/fix/bulk-wizard-skill-filter
fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word 87738f -
fix(bulk): refuse a skill the queue cannot run on the chosen entity type
BulkConsumer::execute() routes on (skill, entity type) pairs and throws on
anything it has no handler for, but the scheduling controller accepted
whatever was posted. A request for a pair the consumer cannot route —
generate on cms_page, say, which the wizard hides but the form will still
carry — was answered with "has been scheduled", split into operations, and
failed one by one where only the bulk log would show it.
Save now checks the pair before it schedules anything, so the operator is
told no while still looking at the screen. The check runs before the entity
ids are resolved, which also spares a full-catalog id query for a request
that was never going to run.
QueueRoutes holds the pairs rather than inlining them, because the
consumer's routing table is the real authority and the two must not drift:
QueueRoutesTest reads the consumer's match arms and asserts they are
exactly the declared list.
It is deliberately not SkillSupport. That asks whether a skill can act on
an entity type at all and answers from getSupportedEntityTypes(); Generate
claims cms_page for the inline editor and is right to, but there is no
generate/cms_page route in the consumer. What a skill can do and what the
queue can carry out are separate facts, and only the second one decides
what may be scheduled.
370 unit tests pass. phpcs --standard=Magento2 reports 0 errors on the
touched files, and Save.php carries the same 10 warnings as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 600476 -
Merge pull request #47 from breezefront/fix/cms-page-translate-scope
fix(translate): stop writing CMS page translations into a category 219f60 -
fix(bulk): do not offer a task whose field panel has nothing in it
The wizard decides what step 2 may offer by asking whether a step 4 panel
exists for the pair. A panel could exist and still be empty: Generate
renders a row per attribute only when a prompt is bound to it, so with no
prompts configured the panel is a bare fieldset. The check said yes, the
operator picked Generate, and step 4 came up blank with a Run button that
never enabled — the dead end the panel check was meant to close, reached
from inside the panel instead of around it.
Prompts are now resolved once up front and an attribute without any is
dropped, which makes "the panel has fields" knowable before rendering.
A panel with no fields is not rendered, so the existing check reports the
truth for it. Translate gets the same treatment for symmetry, though its
field list is only empty on entity types nobody can select today.
That leaves step 2 able to come up with nothing at all in it, so it now
says why instead of showing an empty box.
phpcs --standard=Magento2 reports 0 errors on the touched file, the same
as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1fe13b -
fix(translate): refuse an unsupported entity type in the skill itself
Narrowing getSupportedEntityTypes() to product and category told the truth
but gated nothing. Three paths still reached a CMS page:
- McpServer::executeSkill() passes entityType straight to GenerateService;
the schema enum is a hint to the client, not a check. translate +
cms_page + targetStore>0 went through Translate::execute(), which took
the attribute list from the input field pool — and the pool still knows
the cms_page fields — translated the text, saved nothing, and reported
success. A silent no-op instead of an error.
- The CMS page form kept its "AI Translate" buttons. Block\Adminhtml\Js
hands translate.js every input field of the entity type, and the form
has no store switcher, so currentStore is always 0: every click ran
translateForAllStores() and died on the new save guard.
- That death cost one AI request. translateForAllStores() calls the skill
first and saves after, so the provider was paid before anything refused
the entity.
Translate::execute() now asserts the entity type against its own
getSupportedEntityTypes() right after resolving it — before the provider
call, so a refused entity costs nothing, and for every caller at once.
saveAttributeForStore() keeps its throw as the last line of defence.
SkillSupport answers "can this skill act on this entity type" for UI code;
Js publishes skills.translate and translate.js renders no buttons when it
is false. Generate still gets the full field list.
Js gained a constructor argument — setup:di:compile.
364 unit tests pass. phpcs --standard=Magento2 reports no errors on the
touched files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 551992 -
refactor(bulk): give the step 4 panel lookup one home
The panel query behind the wizard's "can this task run here" check was
written out three times over. wizardCanRun() and hasFieldSelection() ran
byte-identical selectors, each with its own empty-argument guard, so a
change to how a panel is addressed had to be made in more than one place
to hold.
findPanel() is now that one place, and both callers read as what they
ask: does a panel exist, and does the panel that exists have a selection.
The Build Landing Page panel also rendered unconditionally, though step 1
offers CMS pages only when the Content Builder is installed. Without it
the panel was unreachable markup in every admin; it now follows the same
condition as the content type it serves.
No behaviour change. 358 unit tests pass; phpcs --standard=Magento2
reports the same 0 errors as before on the touched file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d13ac -
fix(translate): stop writing CMS page translations into a category
translateForAllStores() routes every non-product entity through
saveAttributeForStore()'s else branch, which loads a *category* by the entity's
id. For a CMS page that id is a page_id, so translating page 8 for all store
views wrote its translated meta_title onto category 8 — on this install, "New
Luma Yoga Collection". A silent write into an unrelated record, no error, no
trace.
Extending the branch is not the fix either: cms_page has nowhere to put a
per-store-view value. The table holds one row per page — title, meta and content
are not store-scoped — and the store assignment lives in cms_page_store. A
translated page in Magento is a separate page. So Translate stops claiming
cms_page, which also narrows the MCP tool's entityType enum, and
saveAttributeForStore() raises on anything it does not explicitly handle instead
of guessing: entity ids collide freely across tables, and guessing corrupts.
Generate keeps cms_page — that path is real. GenerateService::loadEntity()
already resolves pages through PageRepository, so the AI button works on the SEO
fields of the CMS page form. content_heading joins them: plain text, same
mechanics as product-name, and the one obvious gap. title and identifier are
left out on purpose (identifier drags url rewrites behind it), and content
belongs to the Build Landing Page skill, which writes the whole document.
Bulk is unaffected: BulkConsumer never handled generate/cms_page and still does
not, so the wizard offers Build Landing Page alone for CMS pages.
Verified against the compiled DI:
cms_page fields content_heading, meta_title, meta_keywords, meta_description
generate [product, category, cms_page]
translate [product, category]
build_page [cms_page]
360 unit tests pass, including one that pins the corruption: translating a CMS
page for all store views now raises and never touches the category repository.
phpcs --standard=Magento2 reports no errors on the touched files. afe08f -
fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word
Picking CMS Pages still listed Generate Content and Translate: both declare
cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
in the previous commit let them through. Choosing either landed on an empty step
4 with no step 5 and no Run button — the exact dead end this branch set out to
remove, just reached by another route.
A skill's own entity list says nothing about whether this screen can drive it.
The wizard now asks the only thing that matters: does a step 4 panel exist for
this (content type, task) pair. That is the condition for not dead-ending, and it
stays true as panels are added or removed.
getSkillEntityTypes() existed only to feed the old check and is dropped with it.
Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
schedules "AI Build Landing Page for 1 CMS page". 92afa6 -
fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word
Picking CMS Pages still listed Generate Content and Translate: both declare
cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
in the previous commit let them through. Choosing either landed on an empty step
4 with no step 5 and no Run button — the exact dead end this branch set out to
remove, just reached by another route.
A skill's own entity list says nothing about whether this screen can drive it.
The wizard now asks the only thing that matters: does a step 4 panel exist for
this (content type, task) pair. That is the condition for not dead-ending, and it
stays true as panels are added or removed.
getSkillEntityTypes() existed only to feed the old check and is dropped with it.
Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
schedules "AI Build Landing Page for 1 CMS page". 7413c9 -
Merge pull request #45 from breezefront/feat/bulk-cms-landing-page
feat(bulk): build landing pages from the AI Bulk Action wizard eeabe7 -
fix(bulk): file the generated page draft under the scheduling admin
BCB drafts are per user: Content/Load reads getDraft($pageId, 0, $currentUserId)
and the collection filters on equality, so a draft written for anyone else is
invisible in the editor. bcb_page_content.user_id is also a foreign key on
admin_user, so the placeholder 0 the consumer was passing failed the insert
outright — every build_page operation died with an integrity violation and no
draft was ever stored. The unit tests missed it because DraftWriter is mocked.
The scheduling admin's id now travels in the payload and DraftWriter requires it,
rejecting 0 before touching the repository. Save refuses to schedule a page build
when the current user cannot be identified, rather than queueing work that cannot
land.
Alongside that:
- CMS Pages is only offered when the Content Builder is installed. Without it
nothing can store the document, so the wizard used to run the whole batch
through the AI only to fail on save. Step 1, the grids and the LISTING map are
all derived from ViewData::getEntityTypes() now.
- Step 4's heading follows the picked task: "Describe the page" for Build Landing
Page instead of the field-list copy, which still said "Select attributes such
as name, description, meta title".
- Operator messages use real names: "AI Build Landing Page for 3 CMS pages"
rather than "AI Build_page for 3 cms_page(s)".
- Step 4 warns that the run replaces the current draft on every selected page.
- Dropped the unused is_active column from the CMS page grid's select.
Tests: DraftWriterTest covers both guards and the write, SaveTest covers the
payload and the messages, BulkConsumerTest asserts the user id reaches the
writer and that a payload without one fails the operation. 359 pass; phpcs
--standard=Magento2 reports no errors. e8f294 -
feat(bulk): build landing pages from the AI Bulk Action wizard
Step 2 listed every registered skill, so Build Landing Page was offered for
products and categories. It declares cms_page and no attributes, leaving step 4
empty and step 5 unreachable — the wizard dead-ended with no error.
Tasks are now filtered by getSupportedEntityTypes() against the content type
picked in step 1, and CMS Pages joins that step so the skill has a real path:
a brief instead of the per-attribute field list, a CMS page grid, and no store
view step (Content Builder documents are store-agnostic).
The generated document is stored as the page's Content Builder draft. The BCB
page flag is left alone: turning it on with nothing published renders a blank
storefront page, so enabling the editor and publishing stay manual. The BCB
dependency stays soft — the repository is resolved by name behind
interface_exists(), like the component catalog reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> afe7f0
swissup/module-breeze-content-builder — 1.2.0 (was 1.0.10)
-
Version 1.2.0 7f774f
-
Changes for support of the contact us page 812853
-
Added h1 for the Text component and switched to tags instead of CSS classes e054e6
-
Added new Map component c2d71b
-
Improve Columns vertical stretch 6b5da8
-
Version 1.1.0 d0ef07
-
Merge pull request #35 from breezefront/feat/ai-components-bridge
feat(content-builder): apply AI-generated components to the working copy (#34) fa5587 -
Added new Video component 94bb8d
-
fix(content-builder): clone the incoming array before installing it as the working copy d02528
-
fix(content-builder): stay a no-op until sub-modules finish initializing 2c2289
-
fix(content-builder): commit a pending dirty edit before pushing the AI snapshot 3a1ce7
-
feat(content-builder): apply AI-generated components to the working copy (#34) 030d4c
-
Added 5 columns layout for the Columns component 4adc04
-
Version 1.0.12 ccc005
-
fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)
Three review findings on the H1 work:
1. The guard counted only Banner components, so it stayed silent on the most
common duplicate: a CMS page with a Content Heading, where
Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
module-theme's html/title.phtml emits <h1 class="page-title">. Load now
reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
same flag from its opts so BLB can declare the product/category page title,
and the guard adds that H1 to the count.
2. The guard only ran on a headline_type change, so pasting an H1 Banner never
warned. It now runs after paste and after content load as well, which also
covers a tree that arrived from an AI build.
3. getDraft() reloaded the draft by id without checking the load succeeded. If
the row was deleted between the collection query and the reload, it returned
an empty model and the caller saved a row with no page_id — rejected by
MySQL, since page_id is NOT NULL with no default. It now falls back to the
seeded-draft path, which is extracted into createSeededDraft().
Load's constructor gained a dependency, so setup:di:compile is needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> abc266 -
feat(banner): allow H1 for the banner headline (#32)
The Banner headline_type prop only offered h2-h6, so a page built entirely
in Content Builder shipped without a primary heading — the document had no
h1 at all, which is an on-page SEO defect.
Add h1 to the option list and to the template's tag whitelist. The default
stays h2, so existing pages and newly inserted Banners render exactly as
before.
Because a page should carry at most one h1, the editor now shows a soft
notice when a headline is switched to h1 while another Banner already uses
it. It warns rather than blocks — a legitimate layout may keep its h1
outside the Content Builder bands.
The Text component's heading_type is deliberately left alone: it renders a
div with a heading class, not a real heading tag, so an "h1" there would be
visual only and misleading.
Fixes #32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ced844 -
fix(content): reload draft through singular load so updated_at advances
getDraft() returned a collection-hydrated item with empty storedData,
so save()'s update-diff wrote every column back unchanged, pinning
updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.
Related to #31 (does not close it — content-loss symptom unconfirmed). b1759b -
fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)
Three review findings on the H1 work:
1. The guard counted only Banner components, so it stayed silent on the most
common duplicate: a CMS page with a Content Heading, where
Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
module-theme's html/title.phtml emits <h1 class="page-title">. Load now
reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
same flag from its opts so BLB can declare the product/category page title,
and the guard adds that H1 to the count.
2. The guard only ran on a headline_type change, so pasting an H1 Banner never
warned. It now runs after paste and after content load as well, which also
covers a tree that arrived from an AI build.
3. getDraft() reloaded the draft by id without checking the load succeeded. If
the row was deleted between the collection query and the reload, it returned
an empty model and the caller saved a row with no page_id — rejected by
MySQL, since page_id is NOT NULL with no default. It now falls back to the
seeded-draft path, which is extracted into createSeededDraft().
Load's constructor gained a dependency, so setup:di:compile is needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6d73f -
feat(banner): allow H1 for the banner headline (#32)
The Banner headline_type prop only offered h2-h6, so a page built entirely
in Content Builder shipped without a primary heading — the document had no
h1 at all, which is an on-page SEO defect.
Add h1 to the option list and to the template's tag whitelist. The default
stays h2, so existing pages and newly inserted Banners render exactly as
before.
Because a page should carry at most one h1, the editor now shows a soft
notice when a headline is switched to h1 while another Banner already uses
it. It warns rather than blocks — a legitimate layout may keep its h1
outside the Content Builder bands.
The Text component's heading_type is deliberately left alone: it renders a
div with a heading class, not a real heading tag, so an "h1" there would be
visual only and misleading.
Fixes #32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 620e7f -
fix(content): reload draft through singular load so updated_at advances
getDraft() returned a collection-hydrated item with empty storedData,
so save()'s update-diff wrote every column back unchanged, pinning
updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.
Related to #31 (does not close it — content-loss symptom unconfirmed). 5c797e -
Remove custom accordion script d8cb73
-
Added new Accordion/FAQ component 3135bd
-
Version 1.0.11 17c28e
-
Use text color for Heading when set in the Text component 0335a1
-
Added font-size field and source button to WYSIWYG editor dfc235
-
Improve grid component styles on 640px-768px screen sizes fe4e91
-
Use banner text color for headings inside banner if set eb8b24
-
Added Feature Left/Right layouts to Grid component b1b27c
swissup/module-breeze-layout-builder — 1.2.0 (was 1.1.1)
-
Version 1.2.0 24fff8
-
Added Contact Us page support d64e46
-
Config option for upcoming TaxVAT switcher in theme header 7f31bd
swissup/module-navigationpro — 1.19.3 (was 1.19.2)
-
Version 1.19.3 421267
-
Do not use `is_active` from remote entity when creating new item
Since we have new option `use_remote_include_in_menu`, use it instead
of syncing `is_active` on item creation. 7c215a -
Fixed not working ribbon for vertical menu 8fdb50
-
LESS 5.3.0 compatibility 922d05
swissup/module-testimonials — 1.4.2 (was )
-
Version 1.4.2 4c12e4
-
Fixed wrong output when rating is not set 64ce08
-
Added show image config and more options for visible slides dropdown 283b05
-
Version 1.4.1 8ca632
-
Slider widget: added arrows/dots configuration 68ea17
-
Version 1.4.0 2ca286
-
Customizable frontend route, closes #12 e38c8c
-
Removed trailing slash in the canonical tag, see #13 109094
-
Fixed captcha for save controller after removing extend from deprecated class 2b4718
-
Fixed saving store relation bc50ab
-
Use escape in admin notification email b334aa
-
Small code fixes 29bd71
-
Rewrote front controllers without extending deprecated class 0d78e2
-
Updated code to use repository 16b611
-
Remove unnecessary setup_version ffbc53
-
Fixed wrong escaping 4ee69c
-
Fixed undefined $avgRating and division-by-zero 5e68bf
-
Removed deprecated call of $this->helper() in template 99e032
-
Replace deprecated code 94f1f1
-
Replace deprecated code db0ca7
-
Do not translate user-configured string 214f9a
-
Remove unused code 4a1b4f
-
Fixed wrong existing customer detecting 098a18
-
Fixed showing email regardless of config 09aacb
-
Fixed N+1 DB writes in mass status action 86330d
-
Added Testimonial Repository and updated controllers to use it 9a2a35
-
Fixed docblocks and const declarations a73587
-
Version 1.3.17 4e27d1
-
Fixed unescaped output detected error, #14 c764e3
-
Version 1.3.16 4f0b8b
-
Fixed wrong Swiper breakpoints config 7f13f2
-
Version 1.3.15 73865b
-
Fixed implicitly marking parameter as nullable error in PHP 8.4 061815
-
Version 1.3.14 95035c
-
Fixed accessibility error: Can't use h5 without all of the previous levels 127197
-
Version 1.3.13 37d4ce
-
Breeze: scroll reveal animations 9f8c52
-
Version 1.3.12 0f70e6
-
Merge branch 'breeze-dynamic-js' 3afab9
-
Lazy load for profile image in widget b5bb4a
-
Version 1.3.11 f70f2c
-
Breeze dynamic js. WIP d10c53
-
Breeze: remove unused file c5460e
-
Improve breeze intregration 63c3a6
-
Version 1.3.10 4eea58
-
Reduce css size 801626
-
Installer command added 026e30
-
Remove empty spaces around text when adding small quotes around (::before, ::after) fed341
-
Revert "Prevent layout shift when using swiper"
This reverts commit 50793e261167585b5dae3151c03fb28acdb02994. edbf60 -
Prevent layout shift when using swiper 50793e
-
Version 1.3.9 27495e
-
PHP Stan fixes 0d973f
-
Fixed access to undefined constant 81023b
-
Do not use return in _construct ddfadb
-
Fixed not-working slides_to_show widget config, closes #11 b369c9
-
Version 1.3.8 436aa7
-
Added email validation in CreateTestimonial Resolver b355ee
-
Removed unused CreateTestimonial Resolver arguments 703253
-
Added email validation in admin form 0e254c
-
Version 1.3.7 cb93be
-
Fixed file upload in Magento 2.4.5+ 4b15a7
-
Version 1.3.6 b7f56e
-
Replaced Zend_Validate usage that was removed in Magento 2.4.6 5065f1
-
Version 1.3.5 fef03f
-
PHP 8.2: fixed dynamic properties creation e84a87
-
Version 1.3.4 594bef
-
Fixed creation of dynamic properties (php 8.2 compatibility) acc604
-
Version 1.3.3 a41d69
-
Use theme's font-family for author name aae183
-
Version 1.3.2 468b50
-
Prevent error when image input is removed from the submit form a7922d
-
Version 1.3.1 706c48
-
Fix: email is exposed regardless of "Show user email" (close #9) 794a57
-
Breeze compatibility for slide-list-widget d726e4
-
MCS fixes 96b6eb
-
Fixed invalid avatar alignment in testimonials list b5fa96
-
Breeze integration for list widget d7da46
-
JSCS fixes b1dcb3
-
Do not open new tab after double click on "load more" button c80881
-
No need to load content with post request. (Fixes sections reload after testimonials load) a4ce90
-
Version 1.3.0 4d01a6
-
Improve graphql StoreConfig extended config data e00c0a
-
Add graphql StoreConfig extended config data 5f05b5
-
add createTestimonial graphql mutation 4483cf
-
add single Resolver/DataProvider/Testimonial 10de83
-
Hide/show resovler fields depends config e64f9d
-
add date format resolver c1309d
-
add image url testimonial resolver b4020e
-
move company html to template d8d833
-
use dataprovider insteadof collection factory 641877
-
rm customerSession injection in resolver 31680d
-
Improve templates e559cc
-
add getTestimonials graphql request 49108a
-
Version 1.2.18 1b301f
-
Fixed incorrect date on non-US locale, closes #8 9310e3
-
Updated get identities code fb6870
-
Controllers code refactored 192ba7
-
Version 1.2.17 9dd35e
-
Fixed bad-looking testimonial link in customer dropdown c22bfb
-
Version 1.2.16 1e96b6
-
Automatically clean page cache when testimonial was changed 3d41b8
-
Added canonical url on testimonials page 833b4a
-
Use short testimonials page url 9ae78c
-
Version 1.2.15 b294a7
-
Added space between slides 10px in slider widget 17e88b
-
Disabled freeMode in slider widget d02ad1
-
Version 1.2.14 ea1293
-
Added image size parameters for slider widget 504de5
-
Fixed php error when uploading image in Magento 2.4.1 1098be
-
Version 1.2.13 9f141b
-
Use swiper instead on slick 93fc69
-
Version 1.2.12 ae5503
-
Fixed error filtering template in M2.4 6622bc
-
Version 1.2.11 2bbedd
-
Translations updated e965c4
-
Translations updated 5d1802
-
Version 1.2.10 ed5056
-
Removed unnecessary status change confirmations 2631fb
-
Version 1.2.9 8ab314
-
Fixed error when admin notification email is empty 262407
-
Improved slider widget bb7e7c
-
Version 1.2.8 df8489
-
Update uk_UA.csv (POEditor.com) 685cfe
-
Update sw_SE.csv (POEditor.com) c87b14
-
Update es_ES.csv (POEditor.com) 6f795a
-
Update ru_RU.csv (POEditor.com) 085215
-
Update pt_PT.csv (POEditor.com) 17c07b
-
Update pl_PL.csv (POEditor.com) 9dabf0
-
Update nb_NO.csv (POEditor.com) ae2a98
-
Update ko_KR.csv (POEditor.com) e25751
-
Update ja_JP.csv (POEditor.com) 6f92d3
-
Update it_IT.csv (POEditor.com) 51b62f
-
Update he_IL.csv (POEditor.com) 5f1aa8
-
Update de_DE.csv (POEditor.com) 827d5b
-
Update fr_FR.csv (POEditor.com) 72e587
-
Update nl_NL.csv (POEditor.com) 45037c
-
Update zh_CN.csv (POEditor.com) c70cd9
-
Update ar_SA.csv (POEditor.com) 572ad5
-
Add files via upload 05afc6
-
Version 1.2.7 c62689
-
Marketplace data added 1c280f
-
Version 1.2.6 b9fd6d
-
Installation instructions for clients added 684aad
-
Version 1.2.5 d1bd25
-
Resize images only by width when height is empty 09e85e
-
Version 1.2.4 a42213
-
Fixed file uploading in Magento 2.3 92c29e
-
Version 1.2.3 e0224a
-
Fixed wrong php doc aa15ac
-
Version 1.2.2 4cf186
-
Up required module-core version
Since 1.8.0 module-core has Model/Media/FileInfo. Module uses it in `di.xml`. 1c4d4f -
Use module-core\Model\Media\FileInfo to get file info f4877d
-
Version 1.2.1 d7e6dd
-
SlickCarousel update 50f3b8
-
Rename composer package to 'swissup/module-testimonials'; ver 1.2.0 cae583
swissup/theme-frontend-breeze-enterprise-apollo — 3.3.0 (was 3.2.0)
-
Version 3.3.0 7f1f00
-
Added Contact Us page installer a791e7
-
Improve switcher label alignment inside mobile slideout a6415d
-
Fixed transparent menu icon for few seconds on slow web connection 7b8ab8
-
Sync gallery slider arrows with theme settings 6ce6f4
-
USP block content updated 96047c
-
Updated footer_top_content block content 946d00
-
Updated BCB homepage: moved to json; updated benefits block content 62bfe1
-
Fixed listing styles on Magento < 2.4.8
LESS ^3.2.0 bugfix: This css block moved to the start of the styles. a38c1c -
Firefox: Fixed horizontal scrollbar inside search slideout 613e59
swissup/module-core — 1.13.1 (was 1.12.27)
-
Version 1.13.1 5b7b24
-
Fixed invisible update instructions when Mollie Payments is installed 72aab6
-
Version 1.13.0 f34c7b
-
Fill the links after ajax refresh b6a316
-
Show loading state on the "Check for Updates" button 1ab16e
-
Use optimized repo to fetch latest releases 1aa8fb
-
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a -
Check theme source files in Virtual Theme Check (#19)
Making a virtual theme physical while its source files are unavailable is
what breaks the storefront with "Required parameter 'theme_dir' was not
passed", so the check now reports the state of the files and the fixer
refuses to touch the themes it cannot read.
- Virtual Theme Check gets Path and Status columns
- Status names the files Magento can't read. Unreadable file is never
reported as a removed one - a failed stat cannot tell them apart
- Fix All opens up the permissions of unreadable source files and skips
the themes that are still unreadable, listing them in the response
- Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
shared by the block and the controller
- Config table styles shared between Modules and Virtual Theme Check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2 -
Mark core section with badge when outdated modules found 9a3764
-
Outdated modules badge and Modules config group (#25) 7df026
-
MCS fix 3b9121
-
Update instructions 0e686f
-
Read links info from marketplace meta 21e95b
-
Keep the last check time when a refresh fails
Removing the stored time was what made the next load re-check the feed.
An unreachable feed left no time at all: the config page had nothing to
display, and every page load kept re-checking until one request made it
through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9 -
Post the modules refresh to an action of its own
The refresh drops the version check throttle and re-reads the remote
source, and a get carries no form key validation - Magento checks it for
post requests only, and the url secret key is optional. Reading the list
stays a get, so the two live in separate actions now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9 -
Keep the module links out of the response
The links cell is rendered by the server and never touched by js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db -
Send only the module metadata the browser renders
The merged Loader record carries the local filesystem path and the
download, license and purchase-code fields of the remote feed. None of
them is rendered by the config page, so keep them on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425 -
Too much red 2e307e
-
Use the same `outdated` class name everywhere
The version span was already renamed, while the row and the js still said
`_outdated` - a row that stopped being outdated kept its red version pill.
Since the row now wears the very same class, the pill rule had to be
scoped to the version cell, or the whole row would turn red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086 -
Report the check time with the modules, and keep outdated rows first
Two things the clients were guessing at:
The `Check for Updates` handler stamped the label with the current time,
but the request may have re-used the stored packages after a failed feed
request - `just now` was not always true. And an automatic load did check
the remote source, yet nothing updated the label at all. The endpoint now
returns `['items' => ..., 'last_check' => ...]`, and the label follows the
time the server reports for every load.
The table was re-rendered cell by cell, so a module that became outdated
kept its old position while the counter above it grew. Mark the rows and
move the outdated ones back to the top - both groups stay sorted by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271 -
Keep the last check time once the check is due
The timestamp was stored with a one hour lifetime and the throttle was
`is the entry still there?`. Handy, but it also meant the config page
could no longer tell how old the data was as soon as the check was due -
exactly when that is worth showing next to `Check for Updates`.
Store it without a lifetime and compare the age instead. Same hour
boundary, and the label now reads `Last checked 2 h ago` instead of
disappearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a -
Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66
-
Hovered color for outdated badge 0a0016
-
Show outdated modules count in config and add Modules group
The admin had no hint that installed Swissup modules have updates
available - one had to open Module Manager to find out.
Add a counter badge to the Swissup tab of the config navigation, and a
Modules group in Swissup > Core listing every installed module with its
installed and latest version, outdated ones first.
Neither of them may slow down the config page, so both render from the
data stored in var/swissup/core (Loader::setOfflineMode()), and the
versions, the counters and the badge itself are then refreshed by a
single ajax request shared by all the consumers. `Check for Updates`
re-runs that request with refresh=1, which drops the version check
throttle and re-reads the remote source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6 -
Move core config to the top as it will display outdated modules 115f37
-
Added ability to immidiately re-check remote source for updates `-r` 357d33
-
Keep the remote packages data in files instead of the cache (#24) cbeecc
-
Trim the comments down to what is not in the code
The two lock docblocks carried the same paragraph, and save() explained
the reasoning that belongs in the commit that introduced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9 -
Serialize the writes with a lock taken before the entry is opened
openFile() truncates whatever it opens, so a lock taken afterwards keeps
the writes apart but not the truncations. Two saves could interleave into
one file: the second one truncates, the first one writes a whole entry,
and the second one then overwrites its beginning - leaving the head of
one entry on the tail of another. The length line does not catch that
when both entries encode to the same size, as fixed width values like a
version hash always do.
Take the lock before opening, on a file of its own - the entry cannot be
opened before the lock is held, and the file lock() uses is likely held
by the caller already, which flock would deadlock against.
Failing to open a lock file is no longer reported as a lock held by
somebody else. A lock left behind by another user cannot be opened at
all, and reporting that as contention kept the caller away from the
entry for good - silently serving a list that could never be refreshed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28 -
Don't download the list that another process has just stored
The version is fetched before the download lock is taken, so a process
that was still asking for it when the lock was released would go on to
download the very list the lock holder had just stored. Look at the
storage once more before downloading.
Also correct what the save() lock claims to do: it serializes the
writes, but the truncation happens in openFile(), outside of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0 -
Reject an entry whose expiration time is not a number
A corrupt header such as `expires:invalid` was cast to zero, which reads
as "never expires" - so an entry that should have gone stale could be
served indefinitely instead of being treated as missing. Validate the
field the same way the length line is validated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16 -
Keep the remote packages data in files instead of the cache
The packages list was kept in the Magento cache, so every cache flush
threw it away and the next admin request had to download it again. It is
now stored under var/swissup/core, where it outlives the flush.
FileStorage writes the expiration time and the data length ahead of the
contents, so an entry knows itself when it goes stale, and a reader can
tell a half-written entry from a complete one - openFile() truncates the
file before the write lock is taken, so a reader can catch the entry
mid-save. An incomplete entry is reported as missing and refetched,
rather than served as if it were whole.
Remote revalidates against the version hash from packages.json, at most
once an hour, and only downloads the full list when that hash moves. The
download itself is guarded by a non-blocking lock, so several admins
hitting the page at once produce one request instead of one each - the
others serve the stored copy, or wait for the download when there is
nothing stored yet. fetch() now reports connection errors and 4xx/5xx
responses by returning an empty body, letting the caller fall back to
the stored data instead of decoding a failure page.
The admin notification feed keeps its last-update timestamp in the same
storage, replacing its own copy of the file handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd -
Small fixes f7eef6
-
Single source of truth for is_outdated, is_installed 37ad01
-
Fixed `Undefined array key "type"` warning b2a9c1
-
Don't show non installed modules when asking for outdated bf5fb1
swissup/theme-frontend-breeze-enterprise-blank — 3.3.0 (was 3.2.0)
-