Content
Content
Shared across every PremiumPress product. 30 classes in inc/Content.
CollectionImage CollectionImage.php
Resolves a "collection-first" image URL for a Site Generator block field: searches a local, user-curated library before anything is generated with OpenAI. Search order: 1. assets/img/_gen/<keyword>/ — flat keyword folders the site owner stock-fills; the folder name is token-matched against the page's niche + brief + block subject, with reserved role folders (hero/banner/card/logo/avatar) as a generic fallback. 2. the niche's bundled content pack (assets/img/<prefix>/<niche>/content1..8), via NicheImage — covers niches that already ship demo imagery. 3. the media library — images generated earlier by OpenAI, tagged ppt_img_niche / ppt_img_role (see Studio\ImageGen::save()), rotated so a grid varies. Returns '' when nothing matches, so the caller can decide whether to generate.
Countries Countries.php
The world's countries — the canonical list behind the /countries/ index page and the listing_country taxonomy. Why a hard-coded list rather than terms alone: a member types their country as free text in the listing editor ("UK", "U.K.", "Great Britain", "England"), and without a canonical target those become four separate countries. normalize() folds every common spelling, alias and ISO code onto ONE canonical name, and Directory\CountryTax turns that name into the term. The list is therefore the vocabulary, not the content. Grouped by continent because that is how the index page reads — 197 names in one A–Z column is a wall; six labelled blocks is a page you can scan.
Currencies Currencies.php
PPT — currency catalog + formatting helper. Drives the Settings → Currency options (code, symbol, position, decimals, separators) and formats prices consistently across the theme.
DemoContent DemoContent.php
Render-time demo content — fake reviews and FAQ shown while the site is in demo/preview mode, so an unpopulated listing still looks complete when reviewed. NOTHING here is written to the database: reviews are transient WP_Comment objects and FAQ items are plain arrays, both generated deterministically from the post id (same listing → same content across page loads). The single source used by every theme fork's Reviews + ListingFaq (Directory / _sp / _at / _cp / _cm). Gate: {@see active()} is true only on the FRONT-END while Preview::isPreview() — i.e. a design preview, or demo mode (no active design) unless ?nopreview=1. On a live site (a design activated) it is false, so real listings never get fake data. Widen/narrow per listing with the ppt_demo_content filter.
DemoLocations DemoLocations.php
Random-but-plausible location data for demo listings — a real street address, region, postcode, country and valid coordinates (so the single-listing map, markers, directions and "distance from me" all have real data to show). Coordinates are a real city centre plus a small deterministic jitter (so demo listings spread out instead of stacking on one pin), keyed by the seed so a given listing always gets the same spot. Unknown cities fall back to a random UK city ("random data from anywhere"). The Marketplace (_mv) sample packs are set in the US, so their cities live in a second, US table (2026-09-28): a US city gets a US street, a 5-digit ZIP, its state as the region and "United States". Before that every seeded _mv product was given a UK address and pin while its shop sat in Denver or Austin. The US table is never a fallback — an unknown city still lands in the UK, as every other fork expects.
EmailTemplates EmailTemplates.php
PPT — system email catalog + settings store. Defines the transactional emails the theme sends (customer + admin), their default subject/body, and the global sending settings (from name/email, domain, header/footer wrapper). Clean rebuild of DT10's ppt_emails / email-settings, without the $CORE engine. Admin overrides live in two options: - ppt_emails => [ key => ['enable'=>bool,'subject'=>str,'body'=>str] ] - ppt_email_settings => ['from_name','from_email','domain','header','footer']
FeatureOverrides FeatureOverrides.php
Site-owner feature overrides — the SUBTRACT-ONLY layer over ThemeProfiles' features matrix. The matrix in ThemeProfiles::featuresMatrix() answers "does this PRODUCT LINE ship this feature", and it is code, not settings: an owner running the Dating Theme could not switch Stories off, because nothing in wp-admin wrote to it. That is the complaint this class exists for — a site arrives with features switched on and no visible way to turn them off. The rule here is deliberately one-way: an owner may switch OFF a feature their product line ships, and may never switch ON one it does not. Forcing a feature on would surface combinations that have no screens behind them (bookings on a coupon site, gifts outside the dating fork), so supports() keeps the matrix as the ceiling and this option only ever lowers it. Written by Admin\Wizard (the setup stepper). Read at ONE chokepoint — ThemeProfiles::supports() — so every supportsXxx() gate, droppedSections() and every admin panel gated on them follow automatically.
FieldNames FieldNames.php
Per-language custom field text — the Display Text, Description and Selection values an admin types once per language on PremiumPress ▸ Custom Fields. Custom field labels are CONTENT, not theme strings. The .mo pipeline (see Ppt\Content\Languages) translates the theme's own chrome, but a field an admin created — or even a seeded one — is a plain string in the ppt_fields option, rendered verbatim. A trilingual site showed "Hair length" to Arabic and Chinese visitors for the same reason category names used to. This is the field half of Ppt\Content\TermNames, and deliberately mirrors it: same tab markup, same "blank means fall back to the default" rule, same one-service-for-every-fork shape. WHY A SERVICE AND NOT A CHANGE TO EACH FORK'S ListingEditor Every product line ships its own ListingEditor copy (twenty of them, each with a private valueLines()), and only the active fork's copy is ever registered. A change made there fixes one product and leaves nineteen. This class instead filters the ppt_fields option itself, which is the single thing every fork's editor, search page and profile panel reads — so one implementation covers all of them and there is one copy to maintain. STORAGE Translations live INSIDE each field definition under i18n, keyed by locale: ['i18n' => ['fr_FR' => ['label' => 'Âge', 'help' => '', 'values' => '']]] They ride the existing option rather than a parallel one so a field carries its translations through reordering, export and the factory reset with no second store to keep in step. A blank value is DELETED rather than stored, so "not translated" and "translated to empty" stay distinguishable for the fallback.
Flags Flags.php
PPT — inline SVG country flags for the language options. Emoji flags don't render on many platforms (Windows shows letter pairs), so these are drawn as local inline SVG — recognisable, simplified marks on a 20×15 rounded field. No external assets.
HeroPhotos HeroPhotos.php
Images shaped for a HERO band — GENERATED, do not hand-edit. Rebuilt by themes\build-hero-manifest.ps1, which build-theme.ps1 runs before it stages, so this can never be stale in a shipped ZIP. Keyed by photo folder ("_dt/sports", "_gen/cars"), widest first, listing only images at least 1200px wide with an aspect ratio of 1.3 or wider. A folder with nothing suitable is ABSENT rather than empty: the hero lookup then returns nothing and Studio\ImageGen generates one, which is the right outcome. Stretching a 1024x1024 card across a hero is not.
Languages Languages.php
PPT — language catalog. Drives the Settings → Language options (default language + the languages the directory offers). Selection is stored in ppt_settings; a future translation layer can read it.
ListingPrice ListingPrice.php
The core listing price pair — Price + Sale price — hard-coded into the listing editor rather than left to a site owner to invent as custom fields. DT10 shipped these as core (custom[price] + custom[old_price], side by side under the description). NEWDT lost them: the only way to price a listing became the pricing-plans repeater, whose CHEAPEST plan is mirrored into the searchable price meta by each fork's ListingEditor::savePricing(). A seller who just wants to type "29.00" had nowhere to type it. DELIBERATELY THE SHOP THEME'S MODEL, KEYS AND ALL. This stores product_price and product_compare_at_price — the exact meta keys Ppt_sp\Product reads — so the price and the sale price ARE the same rows across the whole product line. A listing priced on a Directory or Software site keeps that price if the profile is switched to Shop, Product::price() picks it up with no migration, and there is one pricing vocabulary to learn instead of one per fork. NOT wired into the Shop fork itself: _sp renders this pair natively in its own Product card (with SKU, stock and variants around it), so rendering the core card there too would ask for the same two numbers twice. renderCard() self-guards. product_on_sale is deliberately NOT written here. Ppt_sp\Product::onSale() treats an empty flag as "derive it from the pricing", so a core-priced listing with a sale price is already on sale without this class claiming ownership of a switch that only the Shop fork's editor exposes.
MailLocale MailLocale.php
PPT — render an email in the LANGUAGE ITS RECIPIENT READS. A member can now choose the language they see the site in (Account\MemberLanguage). An email that then arrives in the site's language undoes most of that: the one piece of the site that reaches them when they are not looking at it is the one piece still speaking a language they did not choose. WHY THIS IS NOT JUST switch_to_locale() --------------------------------------- switch_to_locale() refuses any locale absent from get_available_languages(), which lists only the locales WORDPRESS CORE has a language pack for — it scans WP_LANG_DIR's top level and never looks inside themes/. This theme ships ten locales of its own; a typical install has core packs for one or two. Measured on the dev install (WP 7.1.1, core: en_GB + zh_CN, theme: ten): switch_to_locale('ar') returns false and silently changes nothing, so an Arabic member's email goes out in English and the feature looks broken while every line of it is "working". So this does BOTH, and neither depends on the other: 1. switch_to_locale() — best effort. When core DOES have the pack this gets get_locale(), date formatting and any core strings right too. A false return is expected and fine. 2. Swapping the ppt text domain directly — unload_textdomain($d, true) + load_textdomain($d, $mofile, $locale) against the .mo Languages\Installer::activePath() resolves. This is what actually translates the email, and it works for all ten locales whatever core has. Verified both ways on ar (switcher refuses, domain swap works) and zh_CN (both work). Restoring is symmetric and in a finally: the domain is unloaded RELOADABLE, so the next __() pulls the request's own language back in just-in-time. A locale must never leak out of one email into the page that sent it. WHAT THIS CANNOT REACH, deliberately: - A template the site owner has REWORDED in Settings ▸ Email is a stored string in whatever language they typed. No locale switch can translate it, and guessing at one would be worse than leaving their own words alone. - Emails WordPress itself sends (password reset, new-user) are core strings sent through core's own switch_to_user_locale(), off the locale user meta. They need a CORE language pack, which is a site setting, not theme code.
Measures Measures.php
PPT — height and weight units: the one place that knows which system a site speaks. WHY THIS EXISTS. Height and weight shipped metric-only on the input side and unconditionally DUAL on the output side — "170 cm / 5'7"" — which is the wrong answer for both audiences at once: a US site reads a centimetre figure it does not think in before the number it does, and a metric site carries a conversion nobody asked for. There was no unit setting anywhere in the theme, so neither could be fixed. STORAGE NEVER CHANGES. Values stay in centimetres and kilograms whatever the site is set to, and that is the point of the design rather than an implementation detail: · no migration — every profile already written stays correct; · the search bands (Ppt_da\SearchPage) compare a stored number against a fixed range, so they keep working untouched; · matchmaking and any future sort read one comparable scale; and · switching the setting is instantly reversible, because nothing was rewritten. Only two things vary: what the EDITOR asks for, and what the PAGE prints. ROUND-TRIPPING IS STABLE, which matters because an imperial site re-saves through the imperial control every time a member edits their profile. 170cm → 5'7" → 170cm and 60kg → 132lbs → 60kg both settle on the first pass and never drift again; the conversions round to the nearest whole stored unit in both directions, so a profile cannot lose a centimetre a year to repeated saves.
Nationalities Nationalities.php
Nationalities — the vocabulary behind the person-profile forks' Nationality field. Nationality started life as a free-TEXT field on the escort and dating profiles, so a directory ended up with "British", "british", "Brit", "UK" and "England" as five different answers: no dropdown worth having, a search facet built from whatever had been typed, and nothing a translator or a matchmaker could act on. It is a TAXONOMY now (listing_es_nationality / listing_da_nationality), and this is the list each fork seeds it from on first run. Deliberately NOT the full ~190. This is a dropdown a member scans on a phone while filling in a profile, and a facet a visitor scans on a search page; the curated set below is the nationalities these directories actually carry, and everything else is one term away on Listings ▸ Nationality. Seeding only ever touches an EMPTY taxonomy (see ProfileSpecs::seedTerms()), so an admin who prunes or extends the list is never overruled, and the one-time migration ADDS a term for any answer already typed that this list has no home for — nobody loses the nationality they entered. Not Content\Countries: that holds country NAMES ("Brazil") for the /countries/ index and the listing_country taxonomy, and a profile row has to read "Nationality: Brazilian". The two lists answer different questions and are kept apart. ADJECTIVES, A–Z. The term slug is sanitize_title() of the name, so these strings are stable ids — renaming one here would orphan the term a live site already carries. Both forks read this one list so the vocabulary cannot drift between them, while each still owns its own taxonomy and its own ppt_es_default_nationality / ppt_da_default_nationality filter for shipping a different set.
NicheImage NicheImage.php
Resolves a niche's listing-photo folder to its prefixed path, e.g. 'dine' -> 'img/_dt/dine/'. Those photos live under assets/img/_dt|_sp|_vt|_cp/<niche>/ (see assets/img layout); this is the one place that maps a bare niche key to the prefix folder it lives under, for the handful of call sites that build a niche's image path dynamically (curated sample listings, the gallery block, Site Generator example content).
ProfileBadges ProfileBadges.php
Profile badges — the small coloured pills a site owner puts on a listing card ("★ GOLD", "VIP", "NEW"), and the site-wide library they come from. Shared, non-fork, exactly like inc/Stories/ and for the same reason: the two product lines that want it are escort, which HAS a fork, and dating, which does not — so the engine can live in neither and sits here instead. Everything self-gates on ThemeProfiles::supportsProfileBadges(), which is false on the other 13 product lines, so this file is inert in their ZIPs. Two halves, deliberately kept apart: 1. The library (one option, {@see OPTION}) — the vocabulary. Each badge is a label plus its two colours, created once and reused across every profile. Managed on Settings ▸ Profile badges, and quick-added straight from the listing editor's sidebar. 2. The assignment (post meta, {@see META}) — which of those badges this profile carries, stored as a list of badge ids. Storing the colours ONCE means recolouring "GOLD" recolours every card that carries it, and renaming it renames them all — the thing a per-listing free-text badge could never do. A profile that carries a badge which is later deleted from the library simply stops showing it; the stale id is dropped on the next read rather than swept, so nothing has to run on a schedule. The card draws these next to the derived ONLINE pill (Directory\ListingCard and the escort fork's copy of it) — the badges are what the owner declares, the ONLINE pill is what the site observes.
SampleListings SampleListings.php
Curated sample listings per niche — ported from DT10. Used by the listings block's preview/showcase mode so a previewed design reads on-brand before a site has real listings. Each row: [title, category, city, price, rating, excerpt] with an optional 7th element = the content image number (1-based) to pin, used when a niche's photos aren't in the same order as its rows (see trades).
SampleVocabulary SampleVocabulary.php
GENERATED by dt10-backups/tools/make-sample-vocab.php — do not edit by hand; re-run it. Every sample-listing category name, wrapped in __() so the translation template carries them. Nothing calls names() at runtime for display: its job is to put these msgids in the .pot/.mo, so that TranslationSync's reverse lookup can translate the category terms a site was seeded with ("Saloon" → "Sedán") — they are created from raw row data in SampleListings and would otherwise have no translation in any language.
StoryHover StoryHover.php
Hover-to-play story clips on a card: the markup, the CSS and the one listener, in a single place so every card shape can use them. A listing card is not one thing in this theme. _es\ListingCard draws the canonical one (search results, related strips, the live listing blocks), and every escort DESIGN ships its own featured-profile block with its own markup (Velvet's rose-gold tier tag, Tiara's board, and so on). The behaviour has to be identical on all of them, so it lives here rather than in any one of them: StoryHover::video($clip, $poster) the <video> + "Story" badge, to drop inside the card's own photo wrapper (which must be position:relative — every card's already is) StoryHover::demoClip($name, $img) demo dressing: a sample clip for the FIRST card on a preview page, so the design showroom shows the feature. Rendered, never written. The card element itself carries data-ppt-storycard; the delegated listener keys off that, so no card needs a particular class name.
TermDescription TermDescription.php
A listing taxonomy's description, printed at the BOTTOM of its archive page — full width, under a hairline, below the results. Why the bottom: the description is the long-form copy an owner writes for a category, a nationality or a place ("Mayfair escorts…"), and on a results page the results are what the visitor came for. Above the grid, a few hundred words push every profile below the fold (the complaint that prompted this); below it, the copy is still on the page, still indexed, and read by whoever scrolls on. The layout is the coupon store page's long-form block (_cp\StoreTax::renderContent) made generic: a full-bleed band closed off at the top by a 1px line, the text in the grid's own .container so it starts on the same left edge as the cards above it. That store block is left alone — it has its own content field and demo dressing — and so are the Agency pages, whose description already opens their archive as the agency blurb. Anything else that prints its own description can opt out with the ppt_term_description_skip filter. Page one only (the StoreTax rule): repeating the same copy under /page/2/, /page/3/… is duplicate content on the site's own pages. Hooked on ppt_category_after_results, which runs after BOTH search layouts and sits outside the results section, so an in-place AJAX filter swap never touches it.
TermNames TermNames.php
Per-language category names and descriptions. The theme's language system translates the theme's OWN strings — "Save", "Add to cart" — out of compiled .mo files. A category name is not a theme string: it is the site owner's content, sitting in wp_terms.name, and no .mo will ever cover it. So a trilingual site showed "Baby clothing" to its Arabic and Chinese visitors. This class closes that for both the name and the description: one tab per ACTIVE language, entered by the owner, swapped in at render time. WHY THIS IS ITS OWN SERVICE, NOT PART OF TermMeta TermMeta is the obvious home — it already owns the per-term image and icon on the same screens. But every product fork ships its own copy of that class (_sp, _at, _rt, …) and only the active fork's copy is registered, so hooks added to one copy never fire on the other fourteen. Putting this in a single service registered once in Theme.php means the feature works on every fork and there is exactly one copy to maintain. Same reasoning as the shared LicenseKeys subsystem. WHERE THE SWAP HAPPENS Filtering get_term / get_terms / get_object_terms means every one of the ~128 places the theme renders a term name picks up the translation for free, instead of each call site having to remember to ask. The filters are FRONT-END ONLY: wp-admin must keep showing the real, default values, or the owner would be editing a translation without realising it — and the theme's own admin screens run over admin-ajax, where is_admin() is true, so they are covered by the same guard. WHY TABS Two fields per language stacked vertically turns a term screen into a very long form the moment a site offers three or four languages, and the fields for the language you are actually working in end up off-screen. One tab per language keeps the form the same height whether a site has two languages or ten. WHAT IS NOT TRANSLATED Slugs. A translated slug means a second URL for one term, which is a routing and SEO decision rather than a labelling one; the slug stays the single canonical identifier and only the visible text changes.
TextOverrides TextOverrides.php
LIVE TEXT EDITOR — the block-copy override layer. A thin layer of per-field text edits that sits ON TOP of whatever a page already renders, so an admin can retype a headline on the live site without the design being frozen. Deliberately NOT a snapshot: the design (or the Save-Design snapshot) stays the base layer untouched, which is what makes "Reset all text" a single delete and Undo a restore of the previous value — including the value null, meaning "no override, show the base again". ADDRESSING. A row is <scope> -> <blockKey>`#`<nth> -> <field> -> string. scope which page/design the edit belongs to, computed by the RENDER path (never guessed by the editor) so a stored edit can never disagree with what was on screen. See Blocks\Renderer::render()'s callers: design:<designKey>:home a registered design rendered straight from PHP snap:<slot> a Studio "Save Design" snapshot pages:<fork>:<slot> a built-in Design > Pages slot Anything else renders with scope '' and gets no overrides at all. #<nth> 0-based occurrence of that block KEY in render order. 19 shipped designs render the same key twice (_bo/Jaunt, _cm/Coral, _da/Grace, _es/Voyage, _ll/Coachly and more), so key-only addressing would edit both. The ordinal is used instead of the raw list index because it survives a theme update inserting an UNRELATED block earlier in the design; and because the key is part of the address, a reshuffle that moves a different block into that position finds no match and the override simply goes dormant rather than landing on the wrong block. The store is read on every front-end render, so it is autoloaded and capped. The undo journal is not read on render and is not autoloaded.
TextStrings TextStrings.php
LIVE TEXT EDITOR, PHASE 2 — the theme's own labels. Phase 1 made a design's block copy editable. But whole screens of this theme are not block copy at all: the Member Hub, account pages, search chrome, buttons, empty states and form labels are __() strings, and until this existed the editor had nothing to offer on them — "no point in having a live text editor if you can't edit anything". v10 (DT10) edited exactly these, through a gettext filter and an option called ppt_translations. This is that feature, with its two defects fixed. ── HOW MARKING WORKS, AND WHY NOT THE OBVIOUS WAY ────────────────────────────────── DT10's filter RETURNED "text</span>". That cannot be ported: esc_attr__() is literally esc_attr(translate(...)), so the span arrives entity-escaped inside the attribute, and this theme has ~2,858 esc_attr__/esc_attr_e call sites. DT10 lived with it by disabling Bootstrap tooltips. Sentinel characters are worse: they survive esc_attr into value="" and POST back into saved data, and substr() truncation can split the UTF-8 sequence so sanitize_text_field() blanks the whole string. So the filter here NEVER alters what it returns. In editor mode it merely RECORDS rendered => msgid, and one pass over the finished page wraps matches found in TEXT-NODE POSITION only. An attribute cannot exist in text-node position, so the entire class of breakage is impossible by construction rather than patched case by case — and because __() returns byte-identical output, nothing downstream (escaping, comparison, truncation, JSON encoding, sanitising) can be affected at all. ── PERFORMANCE ───────────────────────────────────────────────────────────────────── inc/Blocks alone holds ~26,000 __() calls, so this must not add a callback to the hottest filter in WordPress for nothing. It doesn't: when a locale has no overrides the filter is NEVER REGISTERED — one autoloaded option read and an === array(). The map is closed over by value, so there is no get_option and no bin2hex per call (DT10 did both, on every string, on every request).
ThemeProfiles ThemeProfiles.php
Theme profiles — the single source of truth for the PremiumPress "theme type" (the ppt_theme option). Each profile carries the display name plus the copy the home-demo needs so its logo, hero and example prompts adapt to whichever theme is installed (e.g. Coupon Theme → "Describe your coupon site").
TranslationSync TranslationSync.php
Fill the per-language copies of database content from an installed .mo. WHY THIS HAS TO EXIST AT ALL Installing a language pack does NOT translate custom field labels or category names, and the reason is easy to get wrong. Those strings were __()-wrapped in a seeder, but the seeder resolved them ONCE and froze the result into the database (ppt_fields, wp_terms.name). Rendering prints the stored string directly — nothing ever re-consults the .mo. Proof: on an Arabic page the heading renders "نظرة عامة" while the field beneath it still reads "Price range", even though "Price range" has a translated entry in ar.po. This service closes that gap by copying the .mo's answer into the per-language fields that Ppt\Content\FieldNames and Ppt\Content\TermNames already render from. REVERSE LOOKUP, NOT A SEED JOIN It asks gettext about the STORED string (translate($stored, 'ppt')) rather than tracing each row back to the seed definition that produced it. That is simpler — no per-fork registry of seed maps, no joining on key or slug — and strictly more capable: a field the CUSTOMER created is filled too whenever its wording exists in the theme's own vocabulary, which for "Parking", "Wi-Fi" or "Payment methods" it usually does. A seed join could never do that. WHAT IT DELIBERATELY WILL NOT DO - It never overwrites a translation someone typed. Empty slots only. - It never invents. A string the .mo has no entry for comes back unchanged and is skipped, so nothing machine-generated reaches a customer-facing label. - It never touches the source row (wp_terms.name, the stored label), so English stays canonical and turning a language off restores the original. ONE exception, localiseDefault(): when the SITE's own language is not English, the source row is the only thing its visitors ever see, so a seeded English name there is translated in place (the English kept in the en_US slot).
Vocabulary Vocabulary.php
Settings ▸ Vocabulary — rename the theme's nouns in what MEMBERS read. An owner names the thing, not each sentence: set Messages = "Chat / Chats" once and every member-facing string carrying that word follows — "You have 3 unread messages", "Send a message" — which a per-string tool leaves behind. WHY THIS IS NOT JUST LOCO TRANSLATE. Loco and the Live Text Editor key on the whole msgid, which is simultaneously too broad and too narrow: renaming the msgid "Search" hits all 350 of its call sites including wp-admin, while leaving the ~200 other member-facing strings that merely CONTAIN the word untouched. WHY IT COMPILES INSTEAD OF SUBSTITUTING AT RUNTIME. English nouns are also verbs, and a blind swap produces nonsense: "No genders match." -> "No genders chats." "Clients search by these." -> "Clients explore by these." No heuristic catches that reliably, so classify() only decides the DEFAULT tick state and the owner unticks the rest. WHERE IT IS STORED. Its own option (self::COMPILED), NOT ppt_text_strings — these rows are generated and rebuilt wholesale on every recompile, so sharing the Live Text Editor's store would mean a recompile deleting the owner's hand edits. The runtime is still one gettext filter, registered at priority 9 so a hand edit (10) wins. SCOPE — member-facing only, decided by the source references in languages/ppt.pot. Three-way, and the distinction is what makes the feature work at all: - VETO Blocks, ppt_blocks, Elementor, tools. Front-end surfaces whose wording must not change; also where the bulk demo prose lives. - IGNORE Admin (a folder OR a fork's AdminX.php), ThemeProfiles, and any dormant fork. Unreachable from the front end, so they neither qualify nor veto — which is why "Messages" can be renamed at all despite wp-admin sharing that msgid, since the filter never registers in wp-admin. - QUALIFY everything else, INCLUDING inc/Designs: a design's __() strings are its header nav items and section headings, exactly the chrome this is for. Nothing in this file or the panel may carry a particular customer's vocabulary — the examples here and in the UI are ordinary words on purpose, and a harness asserts it.
WidgetList WidgetList.php
WidgetList — the owner's sidebar panel list, for any product line that has one. One ordered list of rows {id, type, on, pages{}, data{}, i18n{}}. Each row names a widget TYPE (a block in the sidebar category), carries that block's own field values, and says which page types it appears on and in what order there. Everything in this class is the same whatever the product is — which is exactly why it moved here on 2026-09-11, when the Job Board line asked for the same screen the Coupon Theme had. A fork's subclass declares only what is genuinely its own: const OPTION where the list is stored const CONTEXTS the page types this product has a rail on contextLabels() what to call them on the screen defaults() the rail a site starts with, before anyone touches the screen …plus four optional hooks for anything a line needs that the others do not ({@see normaliseExtras()}, {@see previewBegin()}, {@see previewEnd()}, {@see previewResetCss()}, {@see previewCss()}). ══ WHY pages IS A MAP, NOT A LIST ═══════════════════════════════════════════════ pages is context => position, not a list of context names. Membership and position are ONE fact: the first cut could not express "second on the home page, fourth on search", because those are two orders over overlapping sets. A context absent from the map means "not on that page". A plain list of names is still accepted on the way in, so anything written against the first shape keeps working. ══ WHY IT IS ITS OWN OPTION ═══════════════════════════════════════════════════════ Never a key inside ppt_settings: that option's sanitiser rebuilds itself from scratch on every save, so a list living in there would be wiped by a save made from any other settings section.
WidgetNames WidgetNames.php
WidgetNames — per-language copy for the coupon sidebar widgets. The widget half of {@see \Ppt\Content\TermNames} (category names) and {@see \Ppt\Content\FieldNames} (custom field labels). Without it a widget heading typed once in English is shown verbatim to every visitor, whatever language they are browsing in — the same bug those two classes were built to fix. ══ WHAT IS TRANSLATABLE ═════════════════════════════════════════════════════════ A widget's own copy, derived from its block's defaults(): every scalar field EXCEPT the ones that hold a URL or an image. Those are addresses, not language — translating them would quietly point a visitor's button somewhere else. ══ STORAGE ══════════════════════════════════════════════════════════════════════ INSIDE the widget row, never a parallel option: $row['i18n']['ar']['title'] = '…' so a translation survives reordering, a page change and an export with no second store to keep in step. Blank is DELETED, which keeps "not translated" distinct from "translated to an empty string". ══ THREE THINGS COPIED FROM THE EXISTING PATTERN, DELIBERATELY ══════════════════ 1. Locale helpers delegate to TermNames. Not reimplemented — every screen has to agree on what "the default language" is, and the subtle part ({@see TermNames::defaultLocale()} reading WPLANG, never get_locale()) is exactly the bit that gets copied wrong. Asking get_locale() would answer 'ar' on precisely the pages where a translation is meant to be swapped in, so the short-circuit below would fire and nothing would ever be translated. 2. Display is ONE filter on the option, so Widgets::all(), forContext() and every renderer get translated copy with no changes of their own. 3. preserveInactive(), because a language the admin has un-offered has no inputs on the screen — a plain save would wipe work that re-offering should bring back. Test the swap with ?l=ar, NOT ?ppt_lang=ar.
WidgetStore WidgetStore.php
WidgetStore — which product line's sidebar widget list this install is using. The Widgets screen, the per-language filter and the rails all need the same answer: whose list is this?* Before the Job Board line adopted the rail there was only one possible answer and every one of them simply said Ppt\_cp\Widgets. This is the one place that mapping lives now, so a third product line is a row in {@see MAP} and nothing else. Keyed on the PROFILE rather than the fork prefix, because that is what the feature matrix gates on ({@see ThemeProfiles::supportsSidebarWidgets()}) and what the admin screen already reads — two names for the same thing would eventually disagree. Returns class NAMES, not instances: everything in a widget list is static, and the callers pass the name straight to Registry-style static calls.