Skip to content

[Refactor] Improve the codebase - #10

Open
ashraful1971 wants to merge 38 commits into
feat/template-conditionfrom
release/1.0.0
Open

ashraful1971 wants to merge 38 commits into
feat/template-conditionfrom
release/1.0.0

Conversation

@ashraful1971

Copy link
Copy Markdown
Contributor

No description provided.

ashraful1971 and others added 30 commits September 19, 2026 00:13
Land the harden-first-release-architecture OpenSpec change: secure public
loop pagination, non-destructive theme compatibility, early foreign-document
preparation, shared library persistence primitives, feature availability,
explicit service composition, the shared Loop layer, the Elementor
compatibility boundary, the forms pipeline, schema and cache lifecycle,
distribution and uninstall hardening, and the real WordPress/Elementor
integration and browser suites.

Verification gate on this exact tree (2026-09-19):
- Fast harness: 270/270
- PHPCS (WPCS + PHPCompatibility): clean
- Architecture dependency boundaries: clean
- Static popup runner: 52/52
- Real WordPress + Elementor 4.2.3 integration suite (disposable site,
  --with-pro, free ACF): 203 tests, 1094 assertions, 6 skipped (ACF Pro
  repeater group), 0 failures
- Real-browser suites: builders 4/4, loop pagination 7/7, popup assets
  13/13, form submissions 13/13

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reorganise the free plugin into the layered layout documented in
docs/ARCHITECTURE.md and finish the WordPress.org release gate.

- Split the old Core layer into Core (lifecycle only), Http, Elementor,
  Loop, Conditions, TemplateLibrary, Integrations/Acf and Support.
  Shared layers never import modules and modules never import each
  other; Forms lives under Modules/Widgets and Modules/Mock is gone.
- Module is a service provider: register() binds into the container,
  boot() wires hooks. Registries are bound by their owners and
  container-built services receive their collaborators by constructor.
- Fix "Current Query" loops on category, tag, author, date and search
  archives, and show editors an inline notice explaining why a loop
  renders nothing for visitors.
- Replace the dependency-boundaries script with
  tests/architecture/check.php (layering, Elementor boundary, no
  planning references in code) and add integration tests for the
  archive fix and the notice.
- Add the release gate: verify-package.py checks version consistency,
  the readme, listing assets and the third-party inventory; CI runs
  Plugin Check on the built archive; docs/RELEASE.md records the
  accepted warnings and the post-review steps.
- Add docs/ARCHITECTURE.md, docs/THIRD-PARTY.md, contributor recipes,
  generated listing images under .wordpress-org/ and the 1.0.0 readme
  changelog; trim docblocks to intent and drop planning references.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Module::is_default_active() replaces the hardcoded default-module list, so
renaming a module can no longer silently break seeding. Core\ModuleCatalog
becomes the single enumeration path shared by seeding and module loading,
reading src/Config/modules.php with a plain require: seeding runs before
modules load, and require_once would hand the second reader true instead of
the array. It also settles each entry on the class name before constructing
anything, so a configured non-module no longer has to be instantiated to be
rejected.

Elemacy::boot_providers() tracks booted providers by identity, making
at-most-once boot the composition root's guarantee rather than each
provider's. ServiceProviderInterface no longer asks implementations to
defend themselves, and both CoreServiceProviders drop their $booted flag.
Two instances of one provider class remain two contributions and each boots.

RegistryIdentifierGuard now accepts a real implementation superseding a mock
stand-in and refuses the reverse, which previously passed silently and could
only downgrade a live feature.

Multisite\SiteBatchIterator deliberately keeps its array return: each
plugin's uninstall.php loads that class by path with no bootstrap and no
Composer autoloader, so it must stay dependency-free.
Elementor Canvas no longer renders the Theme Builder header or footer, as with
Elementor Pro. The generic adapters printed the header on wp_body_open, which
Canvas fires, so a popup preview shared its body with the site header: the
preview CSS centred the two as a pair, and the header's widgets firing ready
events made an empty popup sprout a close button. Canvas is detected with
Elementor's own elementor-template-canvas body class, so every route to it is
covered. A header or footer template is also no longer resolved as its own
header or footer -- scoped to a request for a library post, because
get_queried_object_id() is a term id on a taxonomy archive and a user id on an
author archive, so an unscoped comparison would silently drop the header from
any archive whose term happened to carry a template's number.

Library listings build their DTOs from $query->posts instead of the_post().
wp_reset_postdata() only restores the main query's current post, and an
Elementor render_widget request for an archive preview has none, so the last
template listed stayed the global post and a Loop widget rendering its item
template tripped Elementor's recursion guard ("The Template ID cannot be the
same as the currently edited template").

A foreign template's atomic stylesheet now survives the editor canvas. Once the
edited document has atomic styles of its own, the editor removes every
local-<id>-...-css link in the preview frame; that pattern carries no document
id, so a header or footer built from atomic elements lost its CSS the moment an
atomic element was added to the page. Registered templates' link ids get an
elemacy- prefix, in the editor preview only.

The popup close button can be placed outside the popup. The box is no longer
the scroll container -- content scrolls in .elemacy-popup__scroll -- and the
Close Button controls share one selector carrying the element type, so they
outrank the popups.css fallback that loads later on the frontend and silently
won the tie for every close-button style setting.

Behavior changes: an ordinary page set to Elementor Canvas no longer shows the
site header and footer, which matches what Canvas means and what Elementor Pro
does; and an existing popup needs its Elementor CSS regenerated (save it, or
Elementor > Tools > Regenerate CSS) before the new close-button selector
reaches the live site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Template precedence: display conditions declare a specificity level and
the most specific matching template wins; date only breaks ties.

Header and Footer templates edit on their own chrome documents, and a
first schema step reconciles stored document types against the registry.
Schema syncs run on init, after every registry is populated.

The generic theme adapter discards the theme's header.php/footer.php
output instead of hiding it with CSS, with a per-side opt-out filter.

Request-scoped render caches register with Core\RequestState, flushed on
`wp` and keyed by blog id.

Release readiness: composer.json ships beside vendor/, the 1.0.0
changelog is a plain feature list, persisted identifiers are a checked
contract, and verify-package.py rejects stale or dirty archives.

Coding standards: the PHPCS ruleset no longer silences whole standards;
WordPress standards plus PSR-12 layout, zero violations.

Tests: precedence, chrome documents and replacement (fixture and real
classic themes, fresh-process renders), atomic rendering, request state,
Pro entitlement and module seeding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Elemacy now only connects the plugin's parts. Site setup (activation,
new-site and bounded network seeding, the per-request seed safety net,
version sync) moves to Core\SiteInstaller, built by container
auto-wiring so it works before any provider registers. The admin menu
tree and REST/AJAX wiring move to CoreAdminMenu and HttpBootstrap, and
the built-in library types to LibraryBootstrap, all booted by
CoreServiceProvider. ModuleCatalog is container-shared between seeding
and module loading.

CoreServiceProvider builds every bootstrap through the container. To
make that safe, PreviewManager and TemplateAssetsRegistrar now take
required dependencies instead of `?Dep = null` defaults, and the
provider binds PreviewContextProviderInterface to the Loop
implementation, so PreviewManager no longer imports a Loop class.

Provider boot drops SplObjectStorage tracking: init() runs once per
request, which is what boots each provider at most once. Tests move
from Elemacy reflection to SiteInstaller's public API; ARCHITECTURE.md
records the container-for-services convention.
Elemacy::init() now makes one call to Core\ModuleLoader, which registers
the catalog's modules, fires REGISTER_MODULES_ACTION and runs
init_modules() in the same order as before. ModuleManager is shared
through the container and never bound, so get_module_manager(), the
loader and REGISTER_MODULES_ACTION listeners all see one instance;
calling get_module_manager() before init() now returns an empty manager
instead of failing on an uninitialized property.

Removes Elemacy::$module_manager, init_module_manager(), load_modules()
and init_modules(). No hook, option, route or menu-order change.
Dynamic Tags now lists its tags in src/Modules/DynamicTags/Config/tags.php,
keyed by group, like the Popups, ThemeBuilder and Widgets modules already
do. A group can name a `requires` class; the WooCommerce group and its
tags register only while WooCommerce is active, as before. TagManager
drops its 51 imports and static singleton for a plain instance with an
idempotent register_hooks(), and fires the new
Hooks::DYNAMIC_TAGS_REGISTER_ACTION (elemacy/action/dynamic_tags/register)
after the built-in tags so add-ons can add or replace tags by name.

Support\ConfigList::instances() replaces the require/instantiate/type-check
loop that ConditionsBootstrap and TriggerRuleBootstrap each repeated;
build() does the same for a list already read.

The registered tag and group list is unchanged, in the same order, with
and without WooCommerce. New integration tests build Elementor's real tag
list in a fresh process for each scenario.
…tion

Register dynamic tags from config and share the config-list loop
Settle four stored shapes and names that would need a migration over
users' data once 1.0 ships:

- Rename the built-in post-url and post-excerpt dynamic tags to
  elemacy-post-url and elemacy-post-excerpt. Elementor Pro registers tags
  under the bare names, and Elementor's tag registry is keyed by name, so
  one plugin's tag replaced the other's. The architecture check now
  rejects a built-in tag without the elemacy- prefix, and lists every tag
  name, widget name and loop data-source key as a persisted identifier.
- Replace the nullsafe operator in DocumentWriter::document_type(), which
  does not parse on the declared PHP 7.4. The architecture check now scans
  shipped PHP for PHP 8-only tokens; PHPCompatibility 9.x predates PHP 8
  and cannot see them.
- Store elemacy_active_modules as slug => bool explicit choices. A module
  with no recorded choice follows its own is_default_active(), so a
  default-active module added later reaches existing sites; seeding
  records no choices. A stored list of slugs still reads as exactly that
  active set and becomes a map on the next toggle. SiteInstaller no
  longer needs ModuleCatalog, and default_active_slugs() is removed.
- Store elemacy_library_index as {shape, index}. Any other shape, older,
  newer or missing, is a miss and is rebuilt rather than read.

Also make the Post Excerpt tag render the whole excerpt when Excerpt
Length is blank, its default; it rendered nothing unless a length was set.

STORAGE.md and CONTRACTS.md now match what the code stores, including
the dynamic tag name table and the rule that released control and module
defaults are frozen. RELEASE.md requires green PHP 7.4 CI in both repos.
No migration step: no release holds the old shapes.
Elemacy Pro now registers every capability whenever it is active; its
license covers updates and support only. Free carries no license logic.

- Remove PageCachePurger and the elemacy/action/cache/purge hook: Pro's
  license transitions were their only caller.
- Update the architecture, contracts, storage and release docs.
- Rewrite the pro-entitlement integration test for an unlicensed Pro that
  still supplies its real conditions, rules, triggers and loop source.
- Expect the new dashboard copy in the admin contract e2e test.
Route access now comes solely from attached middleware; a route without
middleware is public. The separate capability policy duplicated
AdminMiddleware on every protected route.

- Remove Route::require_capability(), public_access(), the group
  `access` option and the "no policy" registration guard.
- Drop require_capability('manage_options') from every route; each keeps
  AdminMiddleware, so responses are unchanged.
- Drop the redundant is_user_logged_in() check from AdminMiddleware.
- Rewrite the transport-policy tests around middleware and cover a
  logged-in non-admin getting 403.
- Update ARCHITECTURE.md and CONTRIBUTING.md.
The plugin has not been released, so no site holds a wrongly typed
library item that a fresh 1.0 install could need repaired, and a fresh
install seeds the schema marker at latest and skips migrations anyway.

Migrator::migrations() now ships empty; the init-priority scheduling,
schema marker and lease stay for the first real migration. Removes the
cursor option, its tests and its STORAGE.md entries.
Nothing has shipped, so there is no stored data or caller to stay
compatible with. Drop SiteResponse::success()/error() (the form
controller now calls json()), the analytics duplicate-counter test and
fixture operation that exercised Pro's removed migration, and a stale
class_alias comment. CONTRACTS.md now says the rename-needs-a-migration
rule starts with the 1.0 release.
Pro enqueues its stylesheet with elemacy-admin-app as a dependency, but in
dev mode Vite injects the app CSS through JS and the handle was never
registered. WordPress silently skips a style with an unregistered
dependency, so every pro:* utility was missing until a production build.
The Hello adapter marked the header side from get_header and only marked the
footer side when a Footer template existed. With a Header-only setup the
shared hello_elementor_header_footer filter therefore still answered for the
header when Hello checked its footer, and the native footer disappeared.

The window was also open during wp_head, when Elementor builds the kit.
Hello registers its footer settings only if the chrome is shown at that
moment, so the footer that did render was blank. Mark the header only for
Hello's own check (the last wp_body_open priority) and the footer at
get_footer, whether or not that side has a template.

Cover the moments Hello asks in the fast harness, and Header-only, Footer-only
and both-sides cases against real Hello in the integration suite.
The generic adapter discarded a theme's header.php or footer.php whole, so a
theme that opens its layout wrappers in one and closes them in the other was
left unbalanced when only one side was replaced. It now reads what it
discarded (LayoutWrapperReader) and prints the unbalanced wrapper tags around
the Elemacy template, falling back to the old output when the markup cannot be
read with certainty.

Every adapter now asks one question, ThemeBuilderManager::replaces_native_
chrome(), so the opt-out filter reaches all of them. Neve, Genesis and
Storefront remove only the callbacks the theme defined (ThemeHookCleaner)
instead of emptying the hook. Block-theme parts are matched by area, with the
header/footer slugs as the fallback.

Add a real-theme suite (Hello, GeneratePress, Kadence, Neve, OceanWP, Blocksy,
Storefront, Twenty Twenty-One, Twenty Twenty, Hestia, Sydney, Zakra and
Twenty Twenty-Five), run on the dedicated and the generic path, and install the
pinned versions in CI. Rename DefaultCompatibility to HelloElementorCompatibility
and GlobalCompatibility to ClassicThemeCompatibility.
A Single, Archive, Search or 404 template set to Elementor Canvas still got
the site header and footer, because every matched location template went
through one wrapper view that never read its layout. Serve a Canvas location
template through Elementor's own Canvas file with the page-templates module's
print callback set to render the location, which is how Elementor Pro's Theme
Builder does it. Elementor's file tags the body as a Canvas page, so the
existing header and footer guard needs no change.

As in Pro, a singular post that has its own non-default layout (Canvas, Full
Width or a theme page template) is served by that layout and the Single
template is not applied to it. A Full Width location template keeps the
wrapper, which also serves block themes and themes with no header.php, and
gains Elementor's full-width body class.

Cover it with a layout integration suite, fast tests for the layout checks and
the missing-module fallback, and a browser journey that authors the Canvas
layout through the editor.
Search templates now offer Display Conditions: a working Search Results
condition in Free and a Search Results: Post Type placeholder that Pro
replaces with the real condition. A rule with a post type outranks the
global one.

Saving a template refuses a newly added rule with no condition type,
and the template form drops rules a newly chosen type does not offer.
Playwright journeys that click through the real Elemacy admin and the
Elementor editor and then check what a visitor sees: module toggles, the
template library, a popup, and Header and Footer authoring. They run
against a disposable site that the harness provisions and cleans up, with
an owned journey author, redacted capture artifacts, and an optional
registration script for a managed release site.

Free-standing: it only adds files under tests/ and does not change the
plugin's runtime code.
tests/coverage lists every shipped feature (conditions, tags, widgets,
templates, loop sources, popup parts, form parts) with how it is tested and
whether a native browser journey covers it yet. check.py fails when a
shipped feature is missing from the inventory, a declaration changed, or a
test reference no longer resolves, and generates a readable roadmap. It
records gaps; passing it is not a release pass, and --require-journeys fails
until every journey is implemented.

The pull request template and CONTRIBUTING ask each change to state its
feature and journey impact.
The Archive Title tag said "Archives" on every Search Results page because
WordPress's archive title has no search case. It now shows "Search Results
for: term" there and is unchanged everywhere else.

An Archive template could only preview a post type, so its title never
matched a real archive. It now previews a taxonomy term (category, tag or any
public taxonomy), an author or a month, and falls back to the post type
archive when a saved choice no longer exists. The preview also forces the
global post as a real archive request does; without it a month archive's
title is empty in the editor.

The term and author choices use live-search pickers built on a small
resolver pattern behind QueryService: one class per kind (post, term, user),
a registry that loads on first use, a config list and a register action for
add-ons. The post picker moved onto it with unchanged results, and the
Taxonomy and Users loop previews dropped their 200-entry dropdowns for the
same pickers (stored ids are unchanged).

Tests: unit tests for the tag and the routing, integration tests on real
posts, terms, users and requests, and four browser journeys (free and Pro).
The shared browser helper now closes Elementor's late Angie modal and redoes
a heading step when the editor preview stalls; the header and footer
journeys also accept the fixture theme's native markup. Coverage inventory,
docs and release notes updated.
tests/integration/__pycache__/setup.cpython-314.pyc was committed by mistake.
__pycache__ is already in .gitignore.
Current Query was a fake entry in the Posts source's post-type select and
only worked, rebuilt from a restricted set of signed facts, inside Theme
Builder archive and search templates.

It is now a built-in source of its own that lists the request's main query
as WordPress ran it, on any page. Its later pages are the page's own
paginated URLs: Loop Grid fetches the clicked link, swaps the loop in place,
keeps the address bar and history in step and falls back to a normal page
load, so no query is rebuilt and the signed loop endpoint refuses the source.

Posts loses every current_query branch and offers real post types only. The
sanitiser, the Posts policy branch, the archive-only notice and the Theme
Builder query facts that existed only for the old design are removed.

Adds an integration suite, two real-browser runners (visitor pagination and
editor authoring), the coverage inventory entry and the docs.
Loop Grid paginated only with numbers and Previous/Next links. It now offers
Load More and Infinite Scroll, which append the next page's items in place for
every data source.

One loader (loop-incremental.js) serves both triggers and two transports: the
signed-definition endpoint, whose response now says what page comes next, and,
for Current Query, the next page's own URL. Items already on screen are
skipped by a new per-item identity attribute, only appended items are
hydrated, and a failed load falls back to a normal page load (Infinite Scroll
stops auto-loading and shows the button first). The control is a real
rel=next link styled like the existing page links, so it works without
scripting and takes the existing Pagination style controls.

The classic render now honours the requested page for a paginated loop.
It always rendered page one for Posts, Terms, Users and ACF Repeater, which
made every paginated URL of a loop show the first page again and would have
broken the no-script link and the fallbacks.

Adds an integration suite, two real-browser runners (visitor behaviour on both
transports; editor authoring), the coverage inventory entries and the docs.
…cepts

Elementor 4.3.5 changed the atomic heading title and button text props from an
html-v3 object to a plain escaped-html string and rejects the old shape when a
document is saved, so the two atomic rendering tests failed while creating
their fixtures. The helper now reads the accepted prop types from the
installed widget and builds the matching value, falling back to html-v3 for
older Elementor.
pro-entitlement.php reads License::client()->is_active() instead of the
removed LicenseManager. The admin-contract runner seeds an active license
record under elemacy_pro_license and checks the license screen: the Active
badge, the masked key, Check now and Deactivate license, and that the raw
key never reaches the page.
An empty params array encoded as [] and failed the admin app's
object-shaped schema, so Create & Edit never redirected to Elementor
and the edit modal never finished loading for popups using Exit
Intent, Logged-in Users or On Popup Close.
Replace the old Nav Menu with a rebuild that matches the Elementor Pro 4.2.2
Nav Menu: the same control IDs, defaults and panel layout, the same look,
and the same submenu, toggle and responsive behaviour, on Elementor Free.

- PHP split into focused parts: four control sections, a renderer, menu
  filters that only touch the widget's own menus, and breakpoint rules
  generated from the site's Elementor breakpoints.
- Pro's stylesheet ported value for value under Elemacy's own classes.
- SmartMenus behaviour reimplemented without jQuery plugins, including
  right-to-left placement.
- A theme menu rendered after the widget keeps its item IDs.
- Tests: control parity with Pro, real-WordPress rendering, 21 browser
  scenarios, a Pro side-by-side parity runner and an authoring journey.
Fixes found by a four-reviewer pre-release review of the Theme Builder.

- Theme types use explicit conditions: no inclusion rule means displayed
  nowhere, 404 is location-wide, and new templates start as Draft with
  their type's broadest condition pre-filled. Popups keep "no conditions
  = everywhere".
- Elemacy yields a location to Elementor Pro when Pro serves it.
- Archive and search conditions, library visibility, location documents,
  template authoring and display, and frontend asset loading are covered
  by the matching specs.
- Uninstall is opt-in via ELEMACY_REMOVE_ALL_DATA.
- Tests: every fix has a test that fails without it; the integration
  suite passes in the free and Elementor Pro profiles, plus 15 browser
  journeys and single-site and network uninstall checks.
Visitor-facing fixes
- Pro triggers and rules work under deferred, delayed and reordered scripts: an
  extension queue plus a per-popup readiness gate that fails closed.
- Every Animation In/Out choice animates in both directions.
- A popup opens automatically at most once per page view; trigger listeners are
  released; "After another popup closes" has a source and cannot loop.
- Click triggers no longer follow dead links, including a link inside a matching
  Elementor widget wrapper.
- Closing stops media; focus stays in the newest modal; scroll lock compensates
  for the scrollbar; ids are normalised; one broken popup cannot disable others.
- A document that throws while rendering is logged and dropped (popups and Theme
  Builder alike); empty, password-protected and self-previewed popups are not
  delivered; top bars render without wp_body_open and show at first paint.
- Saving rejects unregistered trigger/rule types and keeps percent-encoding.

Contracts and tests
- Public markup, events, JS API and extension queue are documented as permanent;
  the unreleased .elemacy-open-popup markup is removed.
- Control defaults are frozen by a snapshot; trigger/rule ids, parameter keys,
  setting names and option values join the persisted-identifier check.
- New route-authorization sweep, server-rendering and Pro delivery integration
  tests, static-browser cases, and the popup journey family (types, triggers,
  rules, interaction, analytics).
Theme Builder
- Header and footer templates no longer render on the wp action. Replacing
  the theme's chrome is decided from the resolved template and the opt-out
  filter alone; templates render at asset collection, after Elementor's
  frontend and Kit styles, so nothing prints before <!DOCTYPE html> (Internal
  Embedding, redirects, ?wc-ajax=) and template CSS loads after Elementor's.
- A header whose widget throws now leaves its area empty instead of falling
  back to the theme header, as Single and Archive templates already did.
- Output Elementor prints while a foreign document is prepared stays in its
  fragment.
- Block themes keep their own header and footer template parts around a
  location template where no Elemacy template applies, rendered before the
  head so their block-support styles load, inside .wp-site-blocks.

Popups
- "Close on Esc" is honoured for modals (default on); non-modal types ignore
  Escape. Layers opened above a modal (Elementor's lightbox) appear above it,
  keep focus and handle their own Escape.
- New popups start with a Page Load trigger; a popup with no triggers opens
  only from links and the JavaScript API, and a trigger-less top bar is not
  prerendered.
- Browser storage keys are scoped to the site (elemacy_{blog}_...).

Final public surface
- Every REST route lives under elemacy/v1.
- Author hooks follow elemacy-popup-{action}; the API is window.elemacyPopups,
  the config elemacyPopupsConfig, events elemacy-popup:opened/closed/destroyed.
  Retired names are removed with no alias.
- One condition id per display check: the Free placeholders for
  general/search_results and archive/all are removed, search/all is offered on
  Header and Footer templates, and Specific Post requires a post.

Tests
- The test asserting the theme-header fallback for a throwing header is
  replaced: a published header is used as published, matching the rule for
  intentionally empty templates.
- New architecture rules for retired public names and unversioned REST routes;
  integration, static-browser and journey cases for every fix above.
Settles the form contract before sites save forms and add-ons build on it,
so later features ship as additions, never as migrations.

- A builder-neutral forms engine in src/Forms (Elemacy\Forms): field
  type, action and form source registries with registration hooks,
  public contracts, the submission pipeline and the renderer. The Form
  widget stays in the Widgets module as the Elementor UI and registers
  the `elementor` form source.
- Stable field IDs (custom_id when valid and unique, else the row _id):
  values, errors and [field id="..."] email placeholders no longer move
  when fields are reordered.
- New field types: URL, Number, Date, Time, Acceptance, Hidden and HTML.
- Cache-safe submission: no public nonce and no 24-hour placement token,
  so cached pages keep working. A logged-in identity needs a valid
  nonce, otherwise the submission is anonymous. The endpoint is now
  elemacy_form_submit, with renamed request fields.
- Actions return their own result: a failing action shows its message
  and stops the rest. New validation, action_run, submitted, rate-limit
  and client-IP hooks, plus elemacy-form:submit/success/error events.
- Fixes: a required checkbox group needs one box, DOM IDs are unique
  per form instance, hidden labels stay accessible, the select
  placeholder works, and runtime strings are translatable.
- Vanilla delegated runtime (no jQuery) handles forms in popups and
  late-loaded content, with elemacy-form-* classes. The widget keeps
  rendering on every request, so repeated forms (loop items) keep
  their own IDs.
- Contract records: docs and the persisted-identifier check (type and
  action keys, control IDs, a defaults snapshot, retired names).
- Tests: the integration suite passes in the free-only, free-pro and
  Elementor Pro profiles; form, popup-asset and journey browser runs,
  a real editor check and an Elementor Pro Form side-by-side check
  all pass.
PlacementResolversTest builds its fixtures through ArchitectureCatalog,
which needs ACF Pro, so the test never ran in the free and free-pro
profiles. With ACF Pro present it failed. Its header, single and footer
had no display conditions, and since the Theme Builder change a template
without an inclusion rule is shown nowhere, so none of them resolved.

The test now saves each type's default conditions, as a new template
gets in the admin. The shared catalog is unchanged, because the other
tests that use it would render a different page with a site-wide header.
The full integration suite passes with Elemacy Pro, Elementor Pro and
ACF Pro: 596 tests, 0 failures, 0 errors.
Visitors now see the same loop the editor shows in every placement.
- Only a plain page used to give the public policy a "current post", and
  nothing gave a "current term". An ACF Repeater set to Current Post was
  empty in Single templates, popups and nested loop items, and "Exclude
  current post/term" was ignored there and on term archives.
- Loop\Placement\HostFacts decides both, the way the editor does: the post
  of the nearest enclosing post item (NativePostItemInterface), else the
  viewed singular post, and the viewed term on a term archive. Each is
  supplied only while publicly viewable. PlacementResolverRegistry merges
  them into every placement and owns those two names.
- PaginationContextSigner re-checks both facts on later pages.

The data-source contract is frozen for 1.0.
- LoopDataSourceInterface::sanitize_ajax_settings() is removed: nothing
  called it. Public pages use PublicLoopSourcePolicyInterface.
- The never-called PublicContextPlacementResolverInterface and
  resolve_public_context() are removed.
- CONTRACTS.md states the rules: frozen methods, optional interfaces for
  anything new, a public policy before visitors see a source, and add-on
  control IDs prefixed with their source key.

Saved loop settings are pinned.
- loop-control-defaults.php and its fixture pin every loop control's ID,
  type and default (Free and Pro), fail on a control Elementor drops as a
  duplicate, and prove every source resolves the same public settings with
  or without its defaults.

Loop Grid and Loop Carousel share their duplicated code through an
abstract LoopWidget, and the empty-state text now says "items".

New tests: loop-host-context.php (fails before this change), plus cases in
placement-resolvers.php, pagination-context-signer.php,
acf-repeater-query-pagination.php and public-loop-pagination.php.
Each built-in source's setting names and defaults now live in one settings
class (PostsSettings, TermsSettings, UsersSettings). Its Elementor control,
its query and its public policy all read the same constants, so a key is
never retyped as a string and the editor, page 1 and page 2 cannot
disagree on a default. Saved setting names and default values are
unchanged; loop-control-defaults.php still pins them.

The loop layer is grouped by feature, so a new contributor finds a whole
source in one folder:
- Loop\Sources\{Posts,Terms,Users,CurrentQuery}\ hold each source's query,
  public policy and settings (formerly split across Sources\, Constants\
  and Pagination\Policies\).
- Loop\Access\ holds what decides what a visitor may see, on page 1 and on
  signed later pages: PublicRenderPolicy, PublicLoopLimits, the signer,
  the saved-definition resolver and its request/definition objects
  (formerly in Pagination\ and Pagination\DTO\).
- Loop\Pagination\ keeps only the pagination UI.
- WordPressPostScope moves into Items\, beside the item that uses it.

The Elementor half of each source stays in Modules\Widgets\DataSources\,
because Loop never touches Elementor. Namespaces, imports, tests, the
coverage catalog and the docs are updated; redundant same-namespace
imports are removed.
A loop inside another loop's item now works at any depth, on later pages
and in the editor, for every data source.
- Every rendered item gets an address: its parent's address plus one
  typed step (post, term, user, acf_row). LoopContext holds LoopFrames
  (item, address, source, template, index, render key) instead of bare
  items, and LoopRenderer pushes each frame before entering its item.
- A later page of a nested loop used to render with no parent at all.
  The signed context now carries the parent address, a fingerprint of the
  chain's content and its render key. SignedParentChain rebuilds the chain
  through the step resolvers registered per kind, refuses it once a parent
  changed or stopped being public, seeds the template lineage, and enters
  every item root first before the policy, query and render run.
- A parent chain is signed only when it rebuilds for a visitor, so a
  draft or private post ID never reaches the readable token.
- One optional contract, ContextualLoopSourceInterface::query(LoopQuery),
  gives a source the enclosing frame; LoopDataSourceInterface is unchanged.
  LoopQueryDispatcher is the one caller.
- One nesting limit, LoopLimits::MAX_DEPTH (10), for rendering, preview,
  signing and rebuilding; a loop past it tells an editor why.

ACF row context is replayed, not walked.
- AcfCursor replaces AcfRowScope: it enters a row at any depth by replaying
  its field-key path on a cleared loop stack, checks every level, sets the
  index directly and restores the exact prior stack. This fixes same-named
  nested repeaters advancing the outer loop, the row_index_offset
  assumption and the per-row walk.
- Post, term and user items hide an enclosing ACF row while entered, so a
  same-named sub-field never answers for them.
- A failed enter() no longer re-enters the outer item (which replaced an
  ACF row's snapshot); the renderer restores the post globals and the ACF
  loop stack it snapshotted instead.

Styles, tags and the editor follow the chain.
- LoopRenderIdentity includes the parent's render key, so one inner widget
  under two parents never shares a style scope.
- The ACF dynamic tags get a Loop Level control (current item up to three
  levels up).
- A loop edited inside a Loop Item template renders inside that template's
  preview item, also when re-rendered alone; the document preview never
  overrides a real item, and a nested preview item brings its ancestors.

Tests: unit tests for addresses, resolvers, frames, the dispatcher and the
preview provider; loop-addresses.php and nested-loops.php integration
suites; the run-nested-repeater-load-more.mjs browser journey. Docs:
ARCHITECTURE.md and CONTRACTS.md.
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