Skip to content

Production hardening: security P0s, PHP 8.3 compat, SEO/GEO, data integrity - #1

Merged
MarsherSusanin merged 30 commits into
mainfrom
fix/prod-hardening
Jun 22, 2026
Merged

MarsherSusanin merged 30 commits into
mainfrom
fix/prod-hardening

Conversation

@MarsherSusanin

@MarsherSusanin MarsherSusanin commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Production-hardening pass on the SEO/GEO CMS, driven by a multi-agent security/readiness review. Each item is a focused commit with tests; full suite green (146 passing, was 124) and composer audit clean.

Security — all P0 blockers closed

  • Dependency CVEs: upgrade laravel/framework 11 → 12 and re-resolve; clears all 19 composer audit advisories (2 high). config.platform.php pinned to 8.2.0 so vendor/ resolves for the production floor, not the dev runtime.
  • Self-update RCE: manual package upload now fails closed — requires a configured public key + a valid detached Ed25519 signature over the ZIP (shared PackageSignatureVerifier, same gate as the cloud path). Mandatory cloud sha256, downgrade/replay protection, zip-bomb guard.
  • Self-update safety: post-apply health check now boots the new code out-of-band over HTTP (5xx → auto-rollback) instead of only pinging the DB.
  • Stored XSS: JSON-LD </script> breakout fixed (JSON_HEX_*); per-translation custom_head_html and the custom_code_embed/html_embed_restricted blocks are now role-gated to advanced roles.
  • Module sandbox: install/update/activate restricted to superadmin; module public/ publishing uses a static-extension allowlist + hardened .htaccess (no more .php webshell).
  • Surface: asset uploads validated against a mime/extension allowlist (rejects .php/.svg); rate limiters on content/admin/LLM APIs; video_embed restricted to https + an allowlist of hosts.

PHP 8.3 / shared hosting

  • intl/bcmath/exif made optional in the installer; putenv/symlink/proc_open guarded with graceful fallbacks; synchronous update given set_time_limit(0)/ignore_user_abort; CI matrix now spans 8.2/8.3/8.4 + a composer audit gate; cron documented as required.

SEO / GEO

  • OpenGraph + Twitter Card tags; host-validated canonical; x-default hreflang. The previously-dead StructuredDataFactory methods are now emitted as a JSON-LD @graph (Organization, WebSite + SearchAction, WebPage/BlogPosting with author/publisher/image, FAQPage from faq blocks).
  • sitemap/llms.txt no longer leak noindex or future-dated (embargoed) content. AI-crawler controls in robots.txt (configurable allow/deny for GPTBot/ClaudeBot/PerplexityBot/…); removed the static robots.txt that shadowed the dynamic, sitemap-aware route.

Correctness / i18n / data integrity

  • Admin API validates locale against supported locales; translation persisters prune removed locales; LocaleResolver::effectiveDefault() prevents the site root redirecting into a 404.
  • Deleting a Page/Post/Category now cascades cleanup of orphaned seo_overrides/content_revisions/preview_tokens/publish_schedules/slug_histories and stale auto-301 redirects; slug rename→revert no longer creates a redirect loop.

Builder / performance / a11y / ops

  • post_listing block server-renders published posts (was a permanently-empty div); image/gallery blocks emit width/height (CLS).
  • Composite (status, published_at) index; LOG_LEVEL default → warning; branded errors/{403,404,419,429,500,503} pages.
  • Public-site a11y: :focus-visible indicators, skip-to-content link + #main landmark, search input aria-label.

Verification

  • composer lint (pint clean on all changed files)
  • composer test (146 passing, 931 assertions)
  • npm run build (no JS changes in this PR)

Deployment Notes

  • migrations: one new migration adds a composite (status, published_at) index to pages/posts (non-destructive, reversible).
  • cache impact: none structural; recommend php artisan optimize:clear after deploy (config/route changes).
  • queue/scheduler impact: none new; cron is now documented as required (scheduled publish/unpublish depends on it).
  • config / env: new keys — seo.ai_bots, cms.uploads.*, cms.admin_api.rate_limit_per_minute, updates.health_check_url/health_check_timeout/max_uncompressed_mb/max_archive_entries. Manual core-update uploads now require CMS_UPDATE_PUBLIC_KEY + a release signature — set the key before using the updater. LOG_LEVEL default is now warning.
  • follow-up actions: JS-builder data-loss fixes (undo/redo, autosave) + vitest; external theme-CSS file; search LIKE-on-LONGTEXT; deploy_hook_token at-rest encryption; image pipeline (resize/WebP/srcset); GDPR/consent.

Note for reviewers: this branch was cut from a working tree that already had in-progress html_public / chrome-builder work. That WIP is intentionally not part of this PR (left uncommitted locally). A few commits do touch files that carried incidental pre-existing edits (e.g. config/updates.php, some Updates services), since they were modified in the same files this hardening pass changed.


Update: landing-builder WIP folded in + review

This branch was built on top of pre-existing landing-builder WIP that is entangled at the file level with the hardening commits (shared layout.blade.php, block renderer, configs). Per discussion, the WIP is included here rather than split into a fragile second PR. Added commits:

  • Landing builder WIP — site chrome/nav builder (configurable header variant/menu-position/logo, 1-level nested nav, public chrome partials), carousel block (schema + cms-carousel render), ManagedPublicRootSyncService (html_public managed-root sync + snapshot/restore for update rollback), EnvWriterService env-value quoting (prevents .env injection), and an index.php ordering fix. Tests: CarouselBlockTest, PageStagePreviewTest, ManagedPublicRootSyncServiceTest.
  • style: satisfy Pint 1.29 — the Laravel 12 upgrade bumped Pint 1.27→1.29 (new fully_qualified_strict_types rule); applied repo-wide so composer lint passes. Formatting only.

Review of the WIP (findings — follow-ups, not blocking the hardening)

  • P0 — carousel data-corruption (JS): normalizeCarouselData filters empty-src slides on every edit/render (page-form.js / page-fullscreen.js), so "add slide" silently no-ops and editing one slide can overwrite a different one. Fix: don't filter in the edit/render hot path; only at final serialization.
  • P2/P3: logo src has no scheme validation (SiteChromeNormalizerService); chrome-builder.js setPath lost its Array.isArray guard; decorateNavLink passes null to parse_url (PHP 8.3 deprecation). Chrome nav output is otherwise XSS-safe (scheme-allowlisted hrefs, Blade-escaped) and the JSON round-trip is loss-free.

Removed two junk artifacts that were in the tree (html_public/storage 2 duplicate symlink, a runtime-generated html_public/modules/.htaccess) and gitignored /html_public/modules.

Full suite green (146 passed); composer lint passes.

MarsherSusanin and others added 30 commits June 22, 2026 02:01
- Upgrade laravel/framework ^11.31 -> ^12.61.1 and re-resolve deps;
  clears all 19 composer-audit advisories (incl. 2 high: Laravel CRLF
  email-rule injection, symfony/mime SMTP injection). composer audit
  now reports no advisories. Test suite green (125 passed).
- Pin config.platform.php to 8.2.0 so vendor/ resolves for the
  production floor (target hosting is PHP 8.3) instead of dev's 8.5.
- Fix JSON-LD stored-XSS: encode structured data with
  JSON_HEX_TAG|HEX_AMP|HEX_APOS|HEX_QUOT (drop UNESCAPED_SLASHES) so a
  </script> in any SEO field can no longer break out of ld+json.
- Prevent module public-asset webshell: ModulePublicAssetsPublisher now
  copies only an allowlist of static extensions (refusing .php/.phar/
  dotfiles) and drops a hardened .htaccess denying script handlers under
  public/modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the authenticated-RCE and silent-broken-deploy P0s in the updater:

- Manual package upload now fails closed: requires a configured signing
  public key AND a valid detached Ed25519 signature over the ZIP bytes
  (same gate the cloud path uses), so an admin/hijacked session without
  the private key can no longer ship arbitrary PHP. Extract the verifier
  into a shared PackageSignatureVerifier used by both paths.
- Make the cloud checksum mandatory (was skipped when sha256 was empty).
- Downgrade/replay protection: refuse to install a version older than
  the installed one (upload-time and in preflight, all sources).
- Preflight requires a public key for manual packages too (was cloud-only).
- Post-apply health check now boots the new code out-of-band via an HTTP
  request (5xx => fail => auto-rollback), instead of only pinging the DB;
  unreachable loopback is treated as unverifiable, not a failure.
- Zip-bomb guard: cap archive entry count and uncompressed size on extract.
- Upload form accepts a .sig file or pasted base64 signature.

Tests: signed-upload success + unsigned/tampered/no-key rejection;
full suite green (127 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-translation custom_head_html was emitted raw into <head> and the
custom_code_embed / html_embed_restricted blocks re-injected author
markup — both with no role check, so any pages:write editor could store
XSS. Now gated to the same advanced roles as Page.custom_code:

- PageTranslationNormalizer / PostTranslationNormalizer abort(403) when a
  non-advanced actor submits a non-empty custom_head_html or a restricted
  code-embed block. Threaded an allow_custom_code flag (computed from the
  actor via a shared LocalizedContentHelpers::actorMayUseCustomCode) from
  PageContentService / PostContentService; duplicate copies trusted
  content so it bypasses the gate.
- Stage-preview passes the previewing user's capability so advanced
  editors keep live preview of custom-code blocks.

Tests: editor blocked on custom_head_html (service) and on a
custom_code_embed block (API e2e); advanced role still allowed. Full
suite green (130 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Installing/updating/activating a module executes its PHP (providers,
migrations, routes), so these must not be reachable by a merely
settings:write-delegated account.

- ModuleController: code-introducing actions (upload, installLocal,
  installBundled, activate, update) now require superadmin via a new
  ensureCanInstall gate; index/docs/deactivate/uninstall keep the
  settings:write-or-superadmin gate.
- recoverExistingInstall now also requires the on-disk manifest version
  to match the requested module, so a stale/planted directory of another
  version is not silently adopted (id-match guard was already present).

Test: a settings:write (non-superadmin) user can view modules but is
forbidden from installing. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Asset uploads (web + admin API) now validated against a configurable
  extension allowlist (cms.uploads.allowed_extensions) via the mimes rule,
  which checks file content — so .php/.phtml/.html and .svg (script-
  carrying XSS vector) are rejected instead of stored under the public
  disk. Default excludes executable/markup types.
- Register named rate limiters (content-api, admin-api, llm) and apply
  throttle middleware to the content/v1 and admin/v1 route groups and the
  paid LLM generate endpoints, closing the unbounded-DoS / LLM cost-abuse
  surface. Limits are config-driven.

Test: disallowed upload types (.svg/.php/.html) rejected with 422. Full
suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SystemCheckService: intl/bcmath/exif marked optional (CMS doesn't use
  them) so install isn't blocked on shared hosts lacking them.
- SetupFinalizationService: guard putenv() with function_exists (often
  disabled on shared hosting); storage:link now falls back to copying the
  directory when symlink() is unavailable instead of silently no-opping.
- FilesystemUpdateDriver::apply: set_time_limit(0)+ignore_user_abort so a
  vendor copy + migrate in one request isn't killed by max_execution_time
  or a dropped connection mid-update.
- CoreBackupService: guard proc_open (pg_dump/mysqldump); degrade to a
  file-only backup with a visible warning instead of hard-failing the
  update on hosts without shell-out.
- CI: matrix over PHP 8.2/8.3/8.4 (was 8.4 only) + composer audit gate.
- docs: cron marked required (scheduled publish silently fails without it).

Full suite green (132 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- head-meta: emit OpenGraph (og:title/description/type/url/site_name/
  locale + locale:alternate/image) and Twitter Card tags, derived from the
  existing SEO view model with graceful fallbacks. Previously none were
  output — broke social unfurling and several AI snippet extractors.
- CmsLayoutViewModelFactory: canonical is now host-validated — an absolute
  off-site canonical (from a stored override) is rewritten to the app host
  keeping path+query, so it can't deindex toward or open-canonical to
  another domain.
- buildHreflangs: add an x-default alternate pointing at the default locale.

Test: SeoHeadTest asserts canonical/OG/Twitter/hreflang+x-default output.
Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SeoController sitemap + llms.txt now filter via the published() scope
  (adds the published_at <= now() embargo check the inline filter missed)
  and skip translations marked robots noindex, so de-indexed/scheduled
  content is no longer advertised to search and AI crawlers.
- StructuredDataFactory: add graph() (assemble a JSON-LD @graph, stripping
  per-node @context), faqFromBlocks() (FAQPage from faq layout blocks) and
  a WebSite SearchAction; enrich article() with author/publisher/image/
  mainEntityOfPage. The Organization/WebSite/FAQPage/BreadcrumbList methods
  were dead code — now emitted.
- Public page/post resolvers assemble an @graph (Organization + WebSite +
  WebPage/BlogPosting + FAQPage) unless an explicit per-translation
  structured_data override is set.

Tests: sitemap/llms.txt exclude noindex+embargoed; head emits @graph with
Organization/WebSite; faq block emits FAQPage. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Admin API (page/post/category) validate translations.*.locale against
  Rule::in(supported_locales) so unsupported locales can't create orphaned
  translation rows / broken hreflang.
- Translation persisters prune locales no longer in the submitted set, so a
  removed/cleared translation stops resolving publicly and frees its slug.
- LocaleResolver::effectiveDefault() returns the configured default only if
  it is actually supported, else the first supported locale; normalize()
  uses it. HomeRedirectController redirects the site root there, so a
  default_locale not in supported_locales no longer 301s '/' into a 404.

Tests: unsupported-locale 422; removed locale pruned on update; effective
default falls back + '/' redirects to a routable locale. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Dynamic /robots.txt now emits explicit per-agent blocks for AI/generative
  crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot, …),
  Allow by default (GEO goal) and Disallow when SEO_ALLOW_AI_BOTS=false.
  Configurable via config/seo.php ai_bots.
- Remove the static html_public/robots.txt and public/robots.txt (and drop
  robots.txt from updates managed_public_paths): the static file was served
  ahead of the dynamic route on shared hosting, silently dropping the
  Sitemap directive and AI-bot policy. The Laravel front controller now
  always serves the dynamic, sitemap-aware robots.txt.

Test: /robots.txt asserts Sitemap + GPTBot/ClaudeBot blocks. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
post_listing previously emitted an empty <div data-category data-limit>
with no hydration, so the block was permanently blank for users and
crawlers. It now server-renders the published posts: queries
PostTranslation by locale via the post published() scope (excludes
drafts + future-dated), optionally filtered by category_slug, ordered
newest-first and limited, emitting a <ul> of titled links + excerpts.

Note: rendered at page-save time (cached rendered_html), so the list
reflects posts as of the last page save — request-time freshness is a
follow-up. Test asserts published posts render and drafts/embargoed do not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…title

video_embed dropped the author URL into an iframe src with only HTML
escaping. It now requires https + a host on cms.custom_code.safe_embed_
domains, so an arbitrary/phishing/javascript: origin can't be embedded;
off-allowlist URLs render nothing. Also add a title attribute to the
iframe (WCAG 4.1.2 name/role/value).

Test: youtube embed renders with title; off-host, http and javascript:
are rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ContentEntityCleanupObserver (Page/Post/Category deleting): purges the
  rows keyed by (entity_type, entity_id) that have no FK — seo_overrides,
  content_revisions, preview_tokens, publish_schedules, slug_histories —
  and deletes the auto-301 redirect rules pointing at the entity's URLs,
  so a deleted entity no longer 301s to a 404 or hijacks a reused slug.
- TranslationSlugObserver: on a slug change, drop the inverse redirect rule
  (new -> old) so renaming a slug back can't create an infinite 301 loop,
  and rewrite chained rules (X -> old) to point at the new URL directly.

Tests: deleting a page purges its orphan rows + stale redirect; reverting
a slug leaves no loop. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add a composite (status, published_at) index on pages and posts so the
  hot published() listing predicate is sargable (only two independent
  single-column indexes existed).
- .env.example LOG_LEVEL debug -> warning: debug logging can capture
  request payloads/secrets on shared hosting.
- Add branded errors/{403,404,419,429,500,503} views (noindex) so failures
  no longer expose the framework default page / stack traces.

Test: unknown URL renders the branded 404; migration runs in the suite.
Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses WCAG findings on the public site:
- Restore focus visibility: the search input previously set outline:none
  (the only indicator). Add a global :focus-visible outline for links,
  buttons, inputs, selects, textareas, summary and tabindex elements, plus
  a focus-visible style on the search field.
- Add a skip-to-content link as the first focusable element, targeting a
  new id="main" (tabindex=-1) on the <main> landmark, so keyboard/SR users
  can bypass the header/nav.
- Give the reusable search input an aria-label (placeholder is not an
  accessible name).

Test: public page exposes the skip link, #main landmark and focus-visible
styles. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The image and gallery blocks rendered <img loading="lazy"> with no
intrinsic dimensions, causing layout shift. When the block carries
width/height (from the media picker) they are now emitted, plus
decoding="async", so the browser reserves space (better CLS/LCP).

Test: image block emits width/height + decoding when provided, and no
bogus attributes when absent. Full suite green (146).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folds in the pre-existing landing-page/chrome work this hardening branch
was built on top of (entangled with the hardening commits at the file
level, so kept in one PR):

- Site header/nav "chrome builder": configurable header variant + menu
  position + logo, nested nav (1 level), public chrome rendering
  (chrome-header/chrome-nav/public-runtime partials, ResolvedChrome
  ViewModelFactory, SiteChromeNormalizerService). Nav hrefs are
  scheme-allowlisted (http/https/mailto/tel) and output is Blade-escaped.
- Carousel block: schema (BlockSchemaValidator) + public render
  (cms-carousel) with overlay/CTA/autoplay/arrows/dots; empty slides
  skipped at render.
- html_public managed public-root sync (ManagedPublicRootSyncService):
  syncs the managed allowlist from the release html_public into the active
  public root, with snapshot/restore for update rollback.
- EnvWriterService: quote/escape .env values (prevents .env injection).
- html_public/index.php: move testoCmsNormalizePath above bootstrap so it
  is defined before use.
- Tests: CarouselBlockTest, PageStagePreviewTest, ManagedPublicRoot
  SyncServiceTest + updated chrome/env tests.
- gitignore /html_public/modules (runtime-published module assets).

Known review findings to address as follow-up (not blocking the rest):
- P0: carousel JS normalize-on-edit drops empty-src slides, so "add slide"
  no-ops and editing a slide can overwrite another (page-form.js /
  page-fullscreen.js normalizeCarouselData).
- P2: logo src has no scheme validation; chrome-builder setPath lost its
  array guard; decorateNavLink parse_url(null) deprecation on PHP 8.3.

Full suite green (146 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Laravel 12 upgrade bumped laravel/pint 1.27 -> 1.29, which enables
the fully_qualified_strict_types rule. Apply it repo-wide so `composer
lint` (pint --test, the CI gate) passes. Formatting only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Carousel (page-form.js, shared by the fullscreen builder via the bridge):
normalizeCarouselSlides filtered out empty-src slides on every edit and
render, so "add slide" silently no-op'd (the new card was dropped before
it rendered) and editing slide N could overwrite a different slide
(re-indexing after the filter). Now it keeps every slide while editing;
empty slides are skipped only for the visual preview
(renderableCarouselSlides) and dropped server-side on save
(PageTranslationNormalizer / renderCarousel already do this). Verified by
adversarial review across every call site in both editors.

Also restrict the chrome logo src to same-origin/http(s)/data:image
(SiteChromeNormalizerService) so other schemes are dropped.

Tests: chrome_logo_src_drops_dangerous_schemes; CarouselBlockTest green.
Full suite green (147 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… scan

Site search ran a leading-wildcard LIKE ('%term%') against the LONGTEXT
body columns (content_plain / rendered_html) — non-sargable full-table
scans — while the FULLTEXT (MySQL) and GIN to_tsvector (Postgres) indexes
the schema builds for exactly this went unused.

applyTextSearch is now driver-aware: substring LIKE on the short columns
(title/excerpt/meta) for partial matches, plus orWhereFullText (MySQL/
MariaDB) or to_tsvector @@ plainto_tsquery (Postgres) on the body columns
so the body stays searchable via the index. sqlite/other drivers search
only the short columns (no LONGTEXT scan).

Test: SiteSearchTest — post matched by title, page by meta_description,
no match for an absent term. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… rest

- RunPublishSchedulerFallbackMiddleware used a non-atomic check-then-act on
  the cms:scheduler:last-run cache key, so concurrent requests could all
  pass the 30s window and run the publish scheduler (and flush caches) more
  than once. Wrap runDue() in an atomic Cache::lock with a re-check inside.
- CoreUpdateSettingsService now encrypts the deploy-hook bearer token at
  rest (Crypt) instead of persisting it in plaintext in theme_settings, and
  decrypts it in resolved(); legacy plaintext values are tolerated.

Test: deploy_hook_token_is_encrypted_at_rest (stored value != plaintext,
resolved() returns plaintext). Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full ~42KB theme stylesheet (structural base + per-theme tokens) was
inlined into every public page and baked into the full-page cache. Now the
large theme-independent base sheet is served from /cms/theme-base.css with
Cache-Control: immutable + a ?v=<content-hash> cache-buster, and only the
small per-theme :root token block is inlined. This shrinks every page's
HTML (and the cached entry) by ~42KB and lets the browser cache the base
sheet across pages — better LCP/transfer.

ThemeCssRenderer exposes baseCss()/baseCssHash()/dynamicCss(); the theme
view model adds theme_css_dynamic + theme_base_css_url (with a fallback to
the old inline theme_css if those are absent).

Test: base-css endpoint immutable + contains structural rules; public page
links it externally and inlines only tokens (no base rules). Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to externalizing the theme base CSS — the a11y focus-visible and
skip-link rules now live in /cms/theme-base.css rather than inline, so the
accessibility test checks them there. Full suite green (153).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Install larastan and run phpstan level 5 over app/. The 192 pre-existing
findings (mostly Eloquent dynamic-property access on un-annotated models)
are captured in phpstan-baseline.neon so existing code is grandfathered
and only NEW violations fail. Wired in as `composer analyse` and a CI step
between lint and test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New "stats" landing block — a responsive grid of value + label cards
(e.g. "500+ / Clients"), the kind of metric strip landing pages use.
Wired through every layer, mirroring the gallery line-format block:

- config/cms.php allowed_types; BlockLeafRendererService::renderStats
  (escaped, skips empty items); PageTranslationNormalizer hasMeaningfulNode
  so it survives save.
- Both editors (page-form.js + page-fullscreen.js): type label, default
  block, summary, live preview, and a "value | label" textarea inspector,
  with from/toStatsLines exposed on the builder bridge.
- .cms-stats grid styling in the base stylesheet.

Tests: server render (value/label cards, empty items dropped) and an
end-to-end create-page path (block persists + renders in rendered_html).
Full suite green (155); phpstan clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…locks

Four new landing blocks wired through every layer (config allowed_types,
BlockLeafRendererService render methods, PageTranslationNormalizer
hasMeaningfulNode, both JS editors via the line-format/fields pattern, and
base CSS):

- hero: heading + subheading + CTA + optional background image + align
  (fields block; CTA url goes through safeLinkUrl).
- features: grid of icon/title/text cards ("icon | title | text" lines).
- testimonial: quote/author/role cards ("quote | author | role" lines).
- pricing: tier cards with name/price/period/feature-list/CTA
  ("name | price | period | feat; feat | label | url" lines).

All renderers escape output, skip empty items, and return '' when empty.
Tests: LandingBlocksTest renders each block, skips empties, neutralises a
javascript: hero CTA. Full suite green (160); phpstan clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sitemapLocale and llmsTxt streamed a full DB cursor scan on every crawler
hit, ignoring the declared seo.sitemap.cache_ttl. They now build the body
(output-buffered) inside Cache::remember keyed by locale with that TTL and
return a normal cached response — so repeat crawls don't re-scan the DB.

Tests: sitemap/llms responses populate their cache keys; leak/embargo
assertions retained (switched from streamedContent to getContent). Full
suite green (161); phpstan clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in (CMS_CONSENT_ENABLED) cookie-consent banner on public pages:
accept/decline, remembered in localStorage, with an optional policy link
(CMS_CONSENT_POLICY_URL). The visitor's choice is exposed as
window.testoCmsConsent ('accept'|'decline') and a testocms:consent event,
so analytics/embed scripts can gate themselves behind consent. Accessible
(role=region, labelled) and styled in the base stylesheet.

Tests: banner present when enabled (with policy link), absent when off.
Full suite green (163).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Edit-mode autosave was a data-loss trap: the editor posts back to the same
URL, so a local draft snapshot survived a successful save and, on the next
load, recoverAutosaveIfNeeded() offered to "restore" it — overwriting the
just-saved page with pre-save content.

Fix: the page/post update redirect now flashes content_saved; the editor
boot surfaces it as justSaved; on a just-saved load the client drops its
now-stale snapshot (and seeds lastSavedFingerprint from the server payload
so autosave resumes cleanly on the next real edit) instead of prompting to
restore. A failed save flashes nothing, so the draft is preserved. Wired
symmetrically for pages and posts.

Tests: AutosaveSignalTest asserts a successful update flashes content_saved
and the edit boot renders justSaved true (and false on a plain load) for
both pages and posts. Adversarially reviewed the JS scope/side-effects.
Full suite green (165); phpstan clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MarsherSusanin
MarsherSusanin merged commit 52d6b9f into main Jun 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant