> */ public const REST_CONTROLLERS = [ \WConvert\Rest\JourneyStatsController::class, \WConvert\Rest\ProductStatsController::class, \WConvert\Rest\ProductHealthController::class, OptinController::class, TemplateController::class, TemplateCatalogController::class, TemplateTransferController::class, RuleController::class, ThemeController::class, \WConvert\Rest\ScheduleController::class, GoalController::class, PlaybookController::class, \WConvert\Rest\PickerController::class, \WConvert\Rest\OccasionController::class, CaptureController::class, BeaconController::class, LeadController::class, DashboardController::class, MonthlyTargetsController::class, MilestoneController::class, DestinationController::class, PrivacyController::class, \WConvert\Rest\ProtectionController::class, \WConvert\Rest\AgentController::class, ]; public function register(ServiceContainer $container): void { $container->register(\WConvert\Protection\Settings::class, static fn (ServiceContainer $c) => new \WConvert\Protection\Settings($c->resolve(OptionStore::class))); $container->register(\WConvert\Protection\Diagnostics::class, static fn (ServiceContainer $c) => new \WConvert\Protection\Diagnostics($c->resolve(TransientStore::class))); $container->register(\WConvert\Protection\Protection::class, static fn (ServiceContainer $c) => new \WConvert\Protection\Protection($c->resolve(\WConvert\Protection\Settings::class), $c->resolve(\WConvert\Protection\Diagnostics::class), new \WConvert\Protection\Verifier())); $container->register(\WConvert\Rest\ProtectionController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\ProtectionController($c->resolve(\WConvert\Protection\Settings::class), $c->resolve(\WConvert\Protection\Diagnostics::class))); $container->register(ProPresence::class, static fn (): ProPresence => new WpProPresence()); // Beside it rather than folded into it: a missing tier is buyable // from us and a missing plugin is not, and collapsing the two shows a // Pro customer an advertisement for Pro (ADR 0026). $container->register(SitePresence::class, static fn (): SitePresence => new WpSitePresence()); // ==================================================================== // WHAT THIS INSTALL CAN TELL VISITORS APART BY. // ==================================================================== // Free fills it with WordPress's own roles; a membership or LMS plugin // adds a source beside them and changes no file here — which is the // whole of "no core change is needed to add one" (#92). It is the same // shape {@see SuppliedRules} and the Destination registry have, and it // adds no list: the one rule type is declared in the manifest like // every other, and this is only where its ANSWERS come from. $container->register( RoleRegistry::class, static fn (): RoleRegistry => (new RoleRegistry())->add(new WpRoleSource()) ); $container->register(Connection::class, static fn (): Connection => new WpdbConnection()); // PHP reads the rule manifest at runtime (ADR 0005). Once, here — the // file is small but it is still a file, and the alternative is every // publish paying a decode. $container->register(RuleVocabulary::class, static fn (): RuleVocabulary => RuleVocabulary::fromManifest()); // **The live registry: which client rule types this install can // actually evaluate.** Free registers the manifest's own free tier; // [[Pro]] adds the premium ones from its provider, into this same // object, exactly as it does with the Destination registry — so the // question the enqueue path asks is answered by which code ran rather // than by a licence or a tier (ADR 0015). Neither side names a rule // type: each asks the one manifest for its own, so the premium split // still adds zero new lists. $container->register( SuppliedRules::class, static fn (ServiceContainer $c): SuppliedRules => (new SuppliedRules()) ->add(...$c->resolve(RuleVocabulary::class)->typesAt( Tier::Free, // The SITE half, asked at registration rather than at the // resolver: a rule needing a store on a site with no store // cannot be evaluated, so registering it would make an // Optin holding it fail silently instead of being visibly // [[Suspended]] (ADR 0027). Nothing free ships declares // one, so this call is vacuous today — but a gate only // Pro's provider applied is a gate free's would re-open. $c->resolve(SitePresence::class) )) ); // The thin resolver of ADR 0012, shared by both of its call sites — // [[Playbook]] prefill and asset enqueue. One object, so the two // cannot read the substitution table differently. $container->register( Degradation::class, static fn (ServiceContainer $c): Degradation => new Degradation( $c->resolve(RuleVocabulary::class), $c->resolve(SuppliedRules::class) ) ); // The template vocabulary, read the same way and for the same reason: // once per request, so a save does not pay a decode (ADR 0010). $container->register( TemplateVocabulary::class, static fn (): TemplateVocabulary => TemplateVocabulary::fromManifest() ); $container->register( TemplateLibrary::class, static function (ServiceContainer $c): TemplateLibrary { $vocabulary = $c->resolve(TemplateVocabulary::class); return TemplateLibrary::deferred( $vocabulary, static fn (): TemplateLibrary => TemplateLibrary::from( $vocabulary, new BundledTemplates(WCONVERT_DIR), $c->resolve(DesignArchive::class) ) ); } ); $container->register(\WConvert\Discovery\GoalGallery::class, static fn (ServiceContainer $c) => new \WConvert\Discovery\GoalGallery($c->resolve(PlaybookLibrary::class), $c->resolve(\WConvert\Discovery\SetupIndex::class), $c->resolve(ProPresence::class), $c->resolve(TemplateCatalog::class))); $container->register(\WConvert\Discovery\SetupIndex::class, static fn (ServiceContainer $c) => new \WConvert\Discovery\SetupIndex($c->resolve(TemplateLibrary::class), $c->resolve(ProPresence::class))); $container->register(\WConvert\Discovery\CollectionLibrary::class, static fn (ServiceContainer $c) => new \WConvert\Discovery\CollectionLibrary(WCONVERT_DIR, $c->resolve(PlaybookLibrary::class), $c->resolve(TemplateCatalog::class))); $container->register(\WConvert\Rest\PickerController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\PickerController($c->resolve(\WConvert\Discovery\CollectionLibrary::class), $c->resolve(TemplateLibrary::class), $c->resolve(\WConvert\Discovery\OccasionStore::class))); $container->register(\WConvert\Discovery\OccasionStore::class, static fn () => new \WConvert\Discovery\OccasionStore()); $container->register(\WConvert\Agent\AgentPublishing::class, static fn (ServiceContainer $c) => new \WConvert\Agent\AgentPublishing($c->resolve(OptionStore::class))); $container->register(\WConvert\Rest\AgentController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\AgentController($c->resolve(\WConvert\Agent\AgentPublishing::class))); $container->register(\WConvert\Template\OptinConfigBuilder::class, static fn (ServiceContainer $c) => new \WConvert\Template\OptinConfigBuilder($c->resolve(TemplateLibrary::class), $c->resolve(TemplateVocabulary::class), $c->resolve(ProPresence::class))); $container->register(\WConvert\Rest\OccasionController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\OccasionController($c->resolve(OptinRepository::class), $c->resolve(\WConvert\Discovery\OccasionStore::class), $c->resolve(TemplateLibrary::class), $c->resolve(TemplateVocabulary::class), $c->resolve(\WConvert\Template\OptinConfigBuilder::class))); $container->register(OptionStore::class, static fn (): OptionStore => new WpOptionStore()); $container->register(PackValidator::class, static fn (): PackValidator => PackValidator::shipping()); $container->register(DesignArchive::class, static function (ServiceContainer $c): DesignArchive { $uploads = wp_upload_dir(null, false); // Everything that can change what validation says. Deactivating Pro // or WooCommerce, or updating WConvert, is a different key and so // a fresh validation, never a cache another configuration filled. $fingerprint = static fn (): string => hash('sha256', (string) wp_json_encode([(new \WConvert\Support\WpProPresence())->installedTier()->value, \WConvert\Template\JourneySupport::active(), \WConvert\Template\CommerceSupport::active(), WCONVERT_VERSION, (string) md5_file(WCONVERT_DIR . \WConvert\Template\TemplateManifest::PATH), (string) md5_file(WCONVERT_DIR . \WConvert\Template\Catalog\VocabularySince::PATH)])); return new DesignArchive( $uploads['basedir'] . '/wconvert-templates', $c->resolve(PackValidator::class), wp_salt('auth'), new \WConvert\Template\Catalog\VerifiedAssets($uploads['basedir'] . '/wconvert-template-images', $uploads['baseurl'] . '/wconvert-template-images'), $fingerprint, [], static fn (): array => $c->resolve(OptinRepository::class)->designReferences(), ); }); $container->register(\WConvert\Template\Catalog\CatalogTransport::class, static fn () => new WpCatalogTransport()); $container->register(TemplateCatalog::class, static fn (ServiceContainer $c): TemplateCatalog => new TemplateCatalog( $c->resolve(OptionStore::class), $c->resolve(\WConvert\Template\Catalog\CatalogTransport::class), $c->resolve(PackValidator::class), $c->resolve(DesignArchive::class), (new \WConvert\Support\WpProPresence())->installedTier(), static function () use ($c): array { $revisions = []; foreach ($c->resolve(TemplateLibrary::class)->all() as $entry) { $id = substr((string) $entry['design_key'], strlen('registered:')); $revisions[$id] = max($revisions[$id] ?? 0, (int) $entry['revision']); } return $revisions; }, static fn (): array => ['woocommerce' => class_exists('WooCommerce')], null, static fn (): array => $c->resolve(TemplateVocabulary::class)->facets(), static fn (): string => determine_locale(), )); $container->register(TemplateTransferController::class, static fn (ServiceContainer $c): TemplateTransferController => new TemplateTransferController( $c->resolve(PackValidator::class), $c->resolve(TemplateVocabulary::class), $c->resolve(TemplateLibrary::class), $c->resolve(OptinRepository::class), $c->resolve(OptinController::class), $c->resolve(PrivacyGuidance::class) )); $container->register(TemplateCatalogController::class, static fn (ServiceContainer $c): TemplateCatalogController => new TemplateCatalogController($c->resolve(TemplateCatalog::class))); // Beside the option store rather than folded into it: what goes // here is a value whose whole meaning is that it expires, which is // the opposite of the derived state ADR 0003 refuses to put in a // transient. $container->register(TransientStore::class, static fn (): TransientStore => new WpTransientStore()); $container->register( PublishedSet::class, static fn (ServiceContainer $c): PublishedSet => new PublishedSet($c->resolve(OptionStore::class)) ); // Above the repository, which publishes and therefore stamps the // activation milestone in the same call (#94). $container->register( MilestoneStore::class, static fn (ServiceContainer $c): MilestoneStore => new MilestoneStore($c->resolve(OptionStore::class)) ); $container->register( Milestones::class, static fn (ServiceContainer $c): Milestones => new Milestones( $c->resolve(MilestoneStore::class), $c->resolve(StatsRepository::class), $c->resolve(HealthStore::class), $c->resolve(DestinationStore::class) ) ); $container->register( MilestoneController::class, static fn (ServiceContainer $c): MilestoneController => new MilestoneController( $c->resolve(Milestones::class) ) ); $container->register( OptinRepository::class, static fn (ServiceContainer $c): OptinRepository => new OptinRepository( $c->resolve(Connection::class), $c->resolve(PublishedSet::class), $c->resolve(RuleVocabulary::class), $c->resolve(MilestoneStore::class) ) ); // **Below the repository, and it takes one.** Installing is no longer // only DDL: an upgrade rebuilds the published set, because that option // is the one piece of derived state nothing rewrites on its own and a // projection shape change would otherwise reach no existing site // (ADR 0003). Registration order does not matter to the container — // these are lazy factories — but reading order does, and the // dependency is the point of the change. $container->register( Installer::class, static fn (ServiceContainer $c): Installer => new Installer( $c->resolve(OptionStore::class), $c->resolve(OptinRepository::class) ) ); // The allowance the whole site shares: one non-autoloaded option, the // way the retention period is, because a site-wide decision has no // Optin to hang on (ADR 0047). Registered before the enqueue path that // reads it, and read by the REST route that writes it. $container->register( SiteFrequency::class, static fn (ServiceContainer $c): SiteFrequency => new SiteFrequency($c->resolve(OptionStore::class)) ); $container->register( LoaderEnqueue::class, static fn (ServiceContainer $c): LoaderEnqueue => new LoaderEnqueue( $c->resolve(PublishedSet::class), $c->resolve(Degradation::class), $c->resolve(SiteFrequency::class), $c->resolve(RoleRegistry::class), $c->resolve(DraftPreview::class) ) ); // A draft shown on the real site to an administrator who asked // (ADR 0151). Beside the loader, which prints it. $container->register( DraftPreview::class, static fn (ServiceContainer $c): DraftPreview => new DraftPreview( $c->resolve(OptinRepository::class), $c->resolve(RuleVocabulary::class) ) ); // The eligibility inspector, which runs on the REAL page — a merchant // does not describe a URL, they visit it. Registered beside the loader // because it is the same front-end read path with one more question // asked of it, and it prints nothing at all unless an administrator // asked (ADR 0048). $container->register( InspectorEnqueue::class, static fn (ServiceContainer $c): InspectorEnqueue => new InspectorEnqueue( $c->resolve(OptinRepository::class), $c->resolve(PublishedSet::class), $c->resolve(Degradation::class), $c->resolve(RuleCatalogue::class), $c->resolve(RoleRegistry::class) ) ); // The two authoring surfaces for an `inline` Optin. The block reads // the published set and the Optins' names to build its picker; the // shortcode takes an id and needs nothing at all, and is registered // here anyway so that both are wired in one place and neither can be // added without the other being noticed. $container->register( InlineOptinBlock::class, static fn (ServiceContainer $c): InlineOptinBlock => new InlineOptinBlock( $c->resolve(PublishedSet::class), $c->resolve(OptinRepository::class) ) ); $container->register( InlineOptinShortcode::class, static fn (): InlineOptinShortcode => new InlineOptinShortcode() ); // The Goal registry is an enum plus the two facts that resolve its // Availability, so there is nothing to read off disk and nothing to // cache (ADR 0019's fifth refusal). $container->register( GoalRegistry::class, static fn (ServiceContainer $c): GoalRegistry => new GoalRegistry( $c->resolve(ProPresence::class), $c->resolve(SitePresence::class) ) ); // Bundled Playbooks are PHP files returning arrays, read once here for // the same reason the two manifests are — the alternative is every // request that touches the gallery paying for the directory // (ADR 0013). Validation happens on the way in, and only here. $container->register( PlaybookLibrary::class, static fn (ServiceContainer $c): PlaybookLibrary => PlaybookLibrary::deferred( static fn (): PlaybookLibrary => PlaybookLibrary::fromDirectory( $c->resolve(TemplateLibrary::class), $c->resolve(TemplateVocabulary::class), $c->resolve(RuleVocabulary::class), WCONVERT_DIR, $c->resolve(DesignArchive::class)->playbooks() ) ) ); $container->register( Prefill::class, static fn (ServiceContainer $c): Prefill => new Prefill( $c->resolve(PlaybookLibrary::class), $c->resolve(TemplateLibrary::class), $c->resolve(TemplateVocabulary::class), $c->resolve(Degradation::class), $c->resolve(PrivacyGuidance::class) ) ); $container->register( OptinController::class, static fn (ServiceContainer $c): OptinController => new OptinController( $c->resolve(OptinRepository::class), $c->resolve(RuleVocabulary::class), $c->resolve(TemplateVocabulary::class), $c->resolve(TemplateLibrary::class), $c->resolve(GoalRegistry::class), $c->resolve(PublishedSet::class), $c->resolve(Degradation::class), $c->resolve(RuleCatalogue::class), $c->resolve(SiteFrequency::class), $c->resolve(MilestoneStore::class), $c->resolve(DestinationStore::class), $c->resolve(DestinationRegistry::class), $c->resolve(\WConvert\Discovery\OccasionStore::class), \WConvert\Template\TemplateMedia::forSite() ) ); $container->register( GoalController::class, static fn (ServiceContainer $c): GoalController => new GoalController( $c->resolve(GoalRegistry::class) ) ); $container->register( PlaybookController::class, static fn (ServiceContainer $c): PlaybookController => new PlaybookController( $c->resolve(PlaybookLibrary::class), $c->resolve(GoalRegistry::class), $c->resolve(Prefill::class), $c->resolve(\WConvert\Discovery\GoalGallery::class) ) ); $container->register( LeadRepository::class, static fn (ServiceContainer $c): LeadRepository => new LeadRepository($c->resolve(Connection::class)) ); $container->register( LeadCapture::class, static fn (ServiceContainer $c): LeadCapture => new LeadCapture($c->resolve(LeadRepository::class)) ); $container->register( LeadLog::class, static fn (ServiceContainer $c): LeadLog => new LeadLog($c->resolve(LeadRepository::class)) ); // The CSV's columns come from the template vocabulary rather than from // the rows, so the header can be written before the first Lead is // fetched — which is what makes a streamed export possible at all. $container->register( LeadCsv::class, static fn (ServiceContainer $c): LeadCsv => new LeadCsv($c->resolve(TemplateVocabulary::class)) ); // Retention lives in one non-autoloaded option, not a column // (ADR 0018). Registered here rather than beside the pruner because // the REST settings route reads it too. $container->register( RetentionPeriod::class, static fn (ServiceContainer $c): RetentionPeriod => new RetentionPeriod($c->resolve(OptionStore::class)) ); $container->register( LeadController::class, static fn (ServiceContainer $c): LeadController => new LeadController( $c->resolve(LeadLog::class), $c->resolve(RetentionPeriod::class), $c->resolve(LeadErasure::class) ) ); $container->register(\WConvert\Rest\ProductHealthController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\ProductHealthController($c->resolve(OptinRepository::class))); $container->register(\WConvert\Rest\ProductStatsController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\ProductStatsController(new \WConvert\Stats\ProductStats($c->resolve(Connection::class)))); $container->register(\WConvert\Rest\JourneyStatsController::class, static fn (ServiceContainer $c) => new \WConvert\Rest\JourneyStatsController($c->resolve(Connection::class))); $container->register( CaptureController::class, static fn (ServiceContainer $c): CaptureController => new CaptureController( $c->resolve(PublishedSet::class), new \WConvert\Lead\JourneyCapture($c->resolve(Connection::class), $c->resolve(\WConvert\Stats\StatsRepository::class), $c->resolve(DestinationStore::class)), $c->resolve(OptinRepository::class), new \WConvert\Lead\CaptureGrant(wp_salt('auth')), $c->resolve(TemplateVocabulary::class), $c->resolve(CaptureRateLimit::class), $c->resolve(\WConvert\Protection\Protection::class) ) ); // ------------------------------------------------------------------ // Destinations, the queue, and health (#30). // ------------------------------------------------------------------ $container->register(Queue::class, static fn (): Queue => new ActionSchedulerQueue()); $container->register( DestinationStore::class, static fn (ServiceContainer $c): DestinationStore => new DestinationStore($c->resolve(OptionStore::class)) ); $container->register( ConnectionStore::class, static fn (ServiceContainer $c): ConnectionStore => new ConnectionStore($c->resolve(OptionStore::class)) ); // Health in its OWN option, separate from the configuration above. // Two jobs completing at once lose an increment to `update_option`'s // read-modify-write, and that race is tolerable for advisory health — // it would not be tolerable if it ate an admin's edit (ADR 0008). $container->register( HealthStore::class, static fn (ServiceContainer $c): HealthStore => new HealthStore($c->resolve(OptionStore::class)) ); $container->register( DeliveryFailures::class, static fn (ServiceContainer $c): DeliveryFailures => new DeliveryFailures($c->resolve(OptionStore::class)) ); // **Pro registers its own types into this same registry** from its // service provider's boot(), by pulling it out of the shared container // (ADR 0015). Free registers two types and **both are in-process** — // every Destination that makes an outbound HTTP call is Pro's (#4, // ADR 0007). MailPoet is a sibling plugin in this process reading this // database; `wp_mail()` is WordPress's own. $container->register( DestinationRegistry::class, static fn (ServiceContainer $c): DestinationRegistry => (new DestinationRegistry( $c->resolve(ProPresence::class), $c->resolve(SitePresence::class) )) // The type that gives `Goal::GrowEmailList` somewhere to put an // address — which is what that Goal claims to do (#87). ->register(new MailPoetDestinationType(new WpMailPoetSubscribers())) // The one type that works on a Standalone install: `wp_mail()` // is WordPress's, so it needs nothing and is never // `unavailable` (ADR 0007, ADR 0026). ->register(new LeadMagnetDestinationType(new WpMailer())) ); $container->register( DeliveryCount::class, static fn (ServiceContainer $c): DeliveryCount => new DeliveryCount( $c->resolve(OptinRepository::class), $c->resolve(StatsRepository::class) ) ); $container->register( PushDispatcher::class, static fn (ServiceContainer $c): PushDispatcher => new PushDispatcher( $c->resolve(DestinationRegistry::class), $c->resolve(DestinationStore::class), $c->resolve(OptinRepository::class), $c->resolve(HealthStore::class), $c->resolve(Queue::class), $c->resolve(ConnectionStore::class) ) ); $container->register( PushWorker::class, static fn (ServiceContainer $c): PushWorker => new PushWorker( $c->resolve(DestinationRegistry::class), $c->resolve(DestinationStore::class), $c->resolve(ConnectionStore::class), $c->resolve(LeadRepository::class), $c->resolve(OptinRepository::class), $c->resolve(HealthStore::class), $c->resolve(DeliveryFailures::class), $c->resolve(Queue::class), $c->resolve(DeliveryCount::class), new \WConvert\Protection\ResourceSendGuard($c->resolve(Connection::class), $c->resolve(\WConvert\Protection\Diagnostics::class)) ) ); $container->register( BulkRePush::class, static fn (ServiceContainer $c): BulkRePush => new BulkRePush( $c->resolve(DestinationRegistry::class), $c->resolve(DestinationStore::class), $c->resolve(OptinRepository::class), $c->resolve(LeadRepository::class), $c->resolve(HealthStore::class), $c->resolve(Queue::class) ) ); $container->register( DestinationController::class, static fn (ServiceContainer $c): DestinationController => new DestinationController( $c->resolve(DestinationRegistry::class), $c->resolve(DestinationStore::class), $c->resolve(ConnectionStore::class), $c->resolve(HealthStore::class), $c->resolve(DeliveryFailures::class), $c->resolve(BulkRePush::class), // The *Test* buttons run through the same dispatcher a capture // does, so a test exercises the real `push()` rather than a // second path that would prove itself and nothing else (#88). $c->resolve(PushDispatcher::class), new \WConvert\Destination\DestinationUsage($c->resolve(\WConvert\Database\Connection::class)) ) ); $container->register( PrivacyController::class, static fn (ServiceContainer $c): PrivacyController => new PrivacyController( $c->resolve(\WConvert\Privacy\DataMap::class), $c->resolve(PrivacyGuidance::class) ) ); $container->register( StatsRepository::class, static fn (ServiceContainer $c): StatsRepository => new StatsRepository($c->resolve(Connection::class)) ); // The analytics screen. It reads `wconvert_stats` and interprets it // through `wconvert_optins`, and the two tables meet in PHP rather // than in a JOIN — which is why it takes two repositories and not a // widened Connection (ADR 0034). $container->register( Dashboard::class, static fn (ServiceContainer $c): Dashboard => new Dashboard( $c->resolve(StatsRepository::class), $c->resolve(OptinRepository::class) ) ); $container->register( DashboardController::class, static fn (ServiceContainer $c): DashboardController => new DashboardController( $c->resolve(Dashboard::class) ) ); $container->register(MonthlyTargets::class, static fn (ServiceContainer $c): MonthlyTargets => new MonthlyTargets( $c->resolve(OptionStore::class), $c->resolve(Dashboard::class) )); $container->register(MonthlyTargetsController::class, static fn (ServiceContainer $c): MonthlyTargetsController => new MonthlyTargetsController( $c->resolve(MonthlyTargets::class) )); $container->register( RateLimit::class, static fn (ServiceContainer $c): RateLimit => new RateLimit($c->resolve(TransientStore::class)) ); $container->register( CaptureRateLimit::class, static fn (ServiceContainer $c): CaptureRateLimit => new CaptureRateLimit($c->resolve(TransientStore::class)) ); $container->register( BeaconController::class, static fn (ServiceContainer $c): BeaconController => new BeaconController( $c->resolve(PublishedSet::class), $c->resolve(StatsRepository::class), $c->resolve(RateLimit::class), $c->resolve(Degradation::class) ) ); $container->register( TemplateController::class, static fn (ServiceContainer $c): TemplateController => new TemplateController( $c->resolve(TemplateLibrary::class), $c->resolve(TemplateVocabulary::class), $c->resolve(ProPresence::class), $c->resolve(PrivacyGuidance::class), $c->resolve(TemplateCatalog::class) ) ); // The rule vocabulary as the builder needs it: the manifest, plus the // words PHP holds so `make-pot` can see them and the [[Availability]] // only this install can resolve (ADR 0026). $container->register( RuleCatalogue::class, static fn (ServiceContainer $c): RuleCatalogue => new RuleCatalogue( $c->resolve(RuleVocabulary::class), $c->resolve(ProPresence::class), $c->resolve(SitePresence::class), $c->resolve(RoleRegistry::class) ) ); $container->register( RuleController::class, static fn (ServiceContainer $c): RuleController => new RuleController($c->resolve(RuleCatalogue::class)) ); // Theme inheritance is an opt-in VALUE COPY, so this reads the site's // palette and nothing stores where a token came from. $container->register(\WConvert\Rest\ScheduleController::class, static fn () => new \WConvert\Rest\ScheduleController()); $container->register(ThemeController::class, static fn (): ThemeController => new ThemeController()); } public function boot(ServiceContainer $container): void { (new \WConvert\Stats\ProductStats($container->resolve(Connection::class)))->hooks(); add_action(\WConvert\Destination\SubmissionDispatcher::RECOVER, [new \WConvert\Protection\ResourceSendGuard($container->resolve(Connection::class), $container->resolve(\WConvert\Protection\Diagnostics::class)), 'prune']); // A plugin updated by overwriting its directory never fires an // activation hook, so the schema has to be able to catch up somewhere // other than activation. // // That somewhere is `admin_init`, and NOT this boot. The version // option is written with autoload=false like everything else WConvert // stores, so reading it here would put a real database query on every // uncached front-end page load — which is the exact harm ADR 0003 // names when it rejects a transient under the payload. WordPress's own // upgrade routines run on `admin_init` for the same reason, and any // admin page load repairs the schema before a merchant can reach a // screen that needs it. add_action('admin_init', static function () use ($container): void { $container->resolve(Installer::class)->upgradeIfNeeded(); }); /* * ==================================================================== * THE SITE'S TIMEZONE CHANGED, SO EVERY SCHEDULE MEANS A DIFFERENT * INSTANT NOW. * ==================================================================== * An [[Optin]]'s window is STORED as the local wall time the merchant * typed and RESOLVED against `wp_timezone()` when the published set is * built (ADR 0050) — which is what makes correcting a wrong site * timezone correct every schedule with it, rather than leaving them * all an hour out forever. * * That only works if something rebuilds. The set is rebuilt on WRITE * and never on a timer (ADR 0003), and a timezone change is not a * write to any Optin, so without this the correction lands on the next * unrelated publish — which may be never. This is the one event other * than an Optin write that changes what the projection would produce. * * Both options, because WordPress writes both when a merchant picks a * zone: a named zone sets `timezone_string` and blanks `gmt_offset`, * and a bare UTC offset does the reverse. * * **Both rebuild, and a settings save therefore rebuilds twice.** That * is deliberate rather than overlooked. The obvious saving — a flag * that lets the first one through and skips the second — is a * REQUEST-scoped flag on a callback registered once per request, so it * skips every timezone change after the first for the life of the * process. `bin/verify-schedule.php` caught exactly that, on a real * WordPress, changing the zone twice: the second change silently did * nothing and the set stayed on the first zone. WordPress fires these * only when the value actually changed, so the price is one extra * rebuild on the rare occasion a merchant edits Options → General. */ $reresolve = static function () use ($container): void { $container->resolve(OptinRepository::class)->rebuildForTimezoneChange(); }; add_action('update_option_timezone_string', $reresolve); add_action('update_option_gmt_offset', $reresolve); $policyChanged = static function () use ($container): void { $container->resolve(OptinRepository::class)->rebuildForPolicyChange(); }; foreach (['wp_page_for_privacy_policy', 'permalink_structure', 'home', 'siteurl'] as $option) { add_action('update_option_' . $option, $policyChanged); } add_action('post_updated', static function (int $id) use ($policyChanged): void { if ($id === (int) get_option('wp_page_for_privacy_policy')) { $policyChanged(); } }); // Every cached page inlines the published set, so a change to it // purges the page caches WConvert knows (ADR 0150). Free's, because // pausing a campaign early is a free act. add_action('wconvert_published_set_changed', static function (): void { PageCache::purge(); }); /* * ==================================================================== * ROUTES REGISTER ON REST INIT; AUTHORING DATA LOADS ONLY WHEN READ. * ==================================================================== * This runs on `plugins_loaded`, which is before `init` — and a * controller is not one object but the graph behind it. * `PlaybookController` pulls the [[Playbook]] library, which `require`s * seven PHP files whose every string is wrapped in `__()` (ADR 0013). * Fifty-seven translations asked for before the text domain is loaded, * on EVERY request, which is #52: WordPress answers with * `_load_textdomain_just_in_time was called incorrectly` and prints it * mid-`plugins_loaded`, so output begins before `` and * nothing after it can set a header. The quieter half is that the * strings do not come back translated, because they were asked for * before there was a domain to translate them against. * * **Nothing is lost by waiting**, and that is what makes this the fix * rather than a deferral bought with something. A controller's entire * `hooks()` was one `add_action('rest_api_init', …)`, so route * declaration already belonged here rather than at plugin boot. * * `rest_api_init` still runs for every REST request. All controllers * must therefore be constructed to declare their routes even when the * request is only a public capture or beacon. The Template and * Playbook libraries keep their typed place in those graphs but defer * globbing, decoding, normalising and translation until a handler * actually reads authoring data. Their first successful read is reused * for the rest of the request. * * NOT behind `is_admin()`, for the reason the two public routes always * needed: a route has to exist wherever `rest_api_init` fires or it * does not exist at all, and the visitor posting a capture or a beacon * is on a page WordPress may serve through any entry point. */ TemplateTransferController::hooks(); add_action('rest_api_init', static function () use ($container): void { foreach (self::REST_CONTROLLERS as $controller) { $container->resolve($controller)->registerRoutes(); } }); /* * ==================================================================== * THE TWO WAYS AN `inline` OPTIN IS PLACED ON A PAGE. * ==================================================================== * `inline` is the one [[Display Type]] that is not an overlay: it * renders where it was embedded and never competes for the screen, so * unlike the other three it needs somewhere on the page to go. These * two are the only things in the plugin that write one * ({@see \WConvert\Frontend\InlineAnchor}). * * ON `init`, AND NOT BEHIND `is_admin()`. Both halves are load-bearing * and for different reasons: * * - `init`, because `register_block_type()` translates `block.json`'s * title through the i18n schema, and this method runs on * `plugins_loaded`. Registering there is #52 again — fifty-seven * translations before there is a domain to translate against, and * `_load_textdomain_just_in_time` printed before ``. * * - Both sides, because each surface needs a side the other does not. * The block's editor script is enqueued in wp-admin and its * `render_callback` runs on the visitor's page; the shortcode is * parsed on the front end and, in the classic editor, has to exist * in wp-admin for anything to know the tag is taken. * * Resolved inside the callback rather than here, for the reason the * REST controllers are: `plugins_loaded` is not the moment to build a * graph nothing has asked for yet. */ add_action('init', static function () use ($container): void { $container->resolve(InlineOptinBlock::class)->register(); $container->resolve(InlineOptinShortcode::class)->register(); \WConvert\Frontend\ContentRegion::register(); }); // On EVERY request, admin included, and not behind `is_admin()`. The // dispatch attaches to a capture, which arrives through REST from a // visitor's page; the worker attaches to an Action Scheduler hook, // which fires from a loopback request that is neither (#4). $container->resolve(PushWorker::class)->hooks(); \WConvert\Queue\RecentPushHistory::hooks(); (new \WConvert\Destination\SubmissionDispatcher( $container->resolve(Connection::class), $container->resolve(\WConvert\Queue\Queue::class), $container->resolve(DestinationStore::class), $container->resolve(DestinationRegistry::class), $container->resolve(HealthStore::class) ))->hooks(); if (!is_admin()) { $container->resolve(LoaderEnqueue::class)->hooks(); // Beside the loader, because it is the same front-end read path // with one more question asked of it. WHEN it runs relative to the // loader is `InspectorEnqueue::PRIORITY`'s to state and is not // restated here — this comment said "and after it" for one commit // after that constant became `- 1`, which is how a duplicated // fact goes stale. It costs an ordinary page view one capability // check and one `isset($_GET[...])`. $container->resolve(InspectorEnqueue::class)->hooks(); $container->resolve(DraftPreview::class)->hooks(); } } }