# One Page Love API — Changelog All notable changes to the public API, docs, and service behaviour. Follows a light [Keep a Changelog](https://keepachangelog.com) style. The API is versioned via URL prefix (`/v1`). Breaking changes ship under a new prefix with `/v1` maintained for at least 6 months after `/v2` GA. Additive changes (new fields, new optional parameters, new endpoints) ship in place without a version bump. --- ## 2026-05-11 ### Added - **`/v1/search` extends synonym matching to the `sections` taxonomy.** `SYNONYM_TAXONOMIES` now includes `sections` (was `post_tag, style, color, genre, pricing, tech`). Section terms with curated `synonyms` in `wp_termmeta` now participate in the Tier 2 synonym match. Resolves the dominant remaining no-results pattern on OPL: designers searching `navbar`, `header nav`, `top nav`, `header`, etc. now resolve to `sections/Header Navigation` instead of falling through to the title-LIKE fallback. Same plumbing handles `footer` → `sections/Big Footer`, `q&a` → `sections/FAQ`, `call to action` → `sections/Call-To-Action`, etc. once the editorial `synonyms` fields are populated. - **Additive for clients.** Response shape unchanged. `/v1/taxonomies?taxonomy=sections` continues to return a `synonyms` array per term — now populated for terms where it was previously always `[]`. The `/v1/search` response is indistinguishable from a name-matched filter; existing integrations get better matches with no code changes. ## 2026-05-06 (later) ### Changed - **`/v1/search` matches multi-word term names as phrases.** New phrase-containment tier above the per-token primary match: if the normalized query contains a multi-word term name as a word-boundary substring, that term becomes a `matched_filter`. Solves the dominant remaining precision bug — queries like `how it works` (3 tokens, none long enough to clear the SEARCH-012 prefix floor against the 11-char `how-it-works` slug) now resolve to `sections/How It Works` (164 sites) instead of falling through to the post_tag fallback and matching `Workspace` (18 sites) via `works → workspace`. Same fix unlocks `pricing table` (was hitting `Pricing` 315 → now `Pricing Table` 303), `twitter feed`, `landing page`, `comparison table`, `dark mode`, etc. wherever a multi-word term name appears verbatim in the query. - **Match logic.** Normalize both query and term name to lowercase + alphanumeric-only + space-separated. Word-boundary substring match (so `designer` does not match `design`). Multi-word names only — single-word terms continue through the per-token tier with the prefix floor intact. Per taxonomy: longest term name wins; ties broken by highest count. Applied to `genre`, `color`, `style`, `sections`, `tech`, `platform`, `typeface`, plus `post_tag` (with the existing ≥10-site threshold). Phrase-tier hits claim their taxonomy; per-token, synonym, and post_tag fallback tiers skip claimed taxonomies. Pipeline order is now **phrase → primary token → synonym → post_tag fallback → title LIKE**. - **Additive for clients.** Response shape unchanged — only the *values* of `matched_filters` get more accurate for multi-word phrase queries. Existing integrations need no code changes; they automatically get better matches. ## 2026-05-06 ### Added - **`/v1/search` surfaces curated template-archive landing pages (additive).** Two new fields on the response schema. Existing integrations are unaffected — these fields are purely additive and clients that don't render template-archive shortcuts can safely ignore them. Detail: Two new fields on the response: `matched_template_archive` (the registered archive whose taxonomy filters exactly equal `matched_filters`, e.g. `tech/tailwind` query → `/tailwind-templates`) and `similar_template_archives` (up to 20 archives that share ≥1 filter and don't contradict any, e.g. `platform/framer` query surfaces `framer-portfolio-templates`, `free-framer-templates`, `framer-app-templates`, …). Sorted by shared-filter count desc, then specificity desc, then registry order. - Lets clients render template archives in **parallel** to inspiration archives (Matched Inspiration Archive ↔ Matched Template Archive, Similar Archives ↔ Similar Template Archives) without needing to detect the literal word "templates" in the query. A user searching `framer` sees both `/platform/framer` (real Framer sites) and `/framer-templates` (Framer templates for sale). - Source of truth: WP theme registry `opl_template_landing_pages` (~80 entries, persisted to `wp_options('opl_template_landing_pages')` on init). opl-api reads via raw SQL, 60s cache. Mirrors the `opl_taxonomy_url_bases` pattern. - Replaces the need for ~80 manually-curated tuning rules of shape "platform-X templates → /platform-X-templates". Tuning rules remain available for genuinely off-the-wall overrides (vernacular, typos) that don't map to a registry entry. - New `TemplateLandingPage` schema in OpenAPI; `SearchResponse` extended; full description in `llms-full.txt` under *Template archive registry*. ## 2026-05-05 (later) ### Fixed - **Term names are now HTML-entity-decoded in API responses.** WordPress stores special characters in `wp_terms.name` encoded (`Health & Healthcare`, `Books & eBooks`, etc.) via `sanitize_term_field('name', ...)` and decodes them on read through `get_term`. opl-api reads `wp_terms.name` over raw SQL, bypassing that filter — consumers were seeing literal `&` in `name` fields. Decoded across `/v1/search` (`matched_filters`, `matching_terms`, `matching_authors`, plus the per-result `genre/sections/authors` arrays), `/v1/taxonomies`, `/v1/inspirations`, `/v1/sections`, and `/v1/stats`. `slug` fields are unaffected (URL-safe by construction). ## 2026-05-05 ### Changed - **`/v1/search` matching is now exact-preferred with a prefix-confidence floor.** When any term has a slug or name exactly equal to a query token, only exact matches are considered for that token; prefix matches are skipped. When no exact exists, the matcher falls to prefix match (token is a prefix of slug or name) gated by a length-ratio floor — the token must cover ≥50% of the matched term. Same rule applies to the `post_tag` fallback. Highest-count term still wins within a tier. Fixes the dominant precision bug in the live search log: `landing page` was firing `genre/Landing Page` *and* `tech/pagePiling.js` (a 4-char `page` token prefix-matching a 13-char tech slug at ratio 0.31), AND-narrowing the result list to 1 site instead of the 2,074 in the genre archive. ### Added - **`/v1/search` noise-token sanitization.** Tokenization now drops two stop-token sets at the source. *Structural* (`a, an, the, of, and, or, for, to, in, on, with`) was already dropped; this release adds *Noise* (`website, site, page, template, templates, design, online`) — domain-specific filler that's typed as part of a phrase but adds no signal in the OPL catalog. Users searching `personal website` mean `genre/Personal`; `landing page` means `genre/Landing Page`; stripping the noise word at tokenize time prevents it from polluting `matched_filters` via prefix collisions with longer slugs. The returned `tokens` array reflects the post-sanitization list. - **`/v1/search` `stopwords` query parameter.** Accepts `all` (default — both sets dropped), `structural` (only grammatical fillers dropped), or `off` (keep every token ≥2 chars). Lets API consumers whose vocabulary treats words like `website` or `template` as meaningful opt out of OPL's domain-specific defaults without losing the structural strip. - Full *Sanitization & stop tokens* section in `llms-full.txt` and the `/v1/search` description in OpenAPI, documenting both stop-token sets, the `stopwords` parameter, and the exact-vs-prefix matching rules. ### Fixed - **Long-slug terms (`pagepiling-js`, etc.) no longer match short generic tokens via prefix.** The previous `slug.startsWith(token)` check accepted any prefix length; the new floor requires the token to cover ≥50% of the slug or name. The legacy `token.startsWith(slug)` reverse-prefix branch (which would match token `landing-pages` against slug `landing`) was removed entirely — it was untriggered in practice and added complexity without semantic value. ## 2026-05-01 (later) ### Added - **`/v1/search` honors curated synonyms.** New Tier 2 in the search matcher: after primary name/slug match and before the `post_tag` fallback, the route consults each term's `synonyms` array (already exposed in `/v1/taxonomies`) across `post_tag, style, color, genre, pricing, tech`. Match logic: every query token must be a prefix of some synonym token, order-independent. Per-taxonomy single pick, highest-count term wins. `post_tag` synonym matches still subject to the ≥10-site threshold. - Examples now resolving: `coral` → Red color (Red.synonyms = `["coral", "crimson"]`); `dark mode` → Dark color; `azure` → Blue color. - Synonyms are editorially curated per term in wp-admin. Synonym-matched filters are indistinguishable from name-matched ones in the response — same shape, same AND-filtering. Discovery: `GET /v1/taxonomies?taxonomy=color` returns every term with its `synonyms` array. Full reference: see the **Synonyms** subsection in `llms-full.txt` and the `/v1/search` description in the OpenAPI spec. ## 2026-05-01 ### Changed - `/v1/search` matching: `post_tag` is now a **secondary filter** (tried only when no primary taxonomy matched, before falling back to title-LIKE). Tags with ≥10 sites attached are eligible. Previously tags only surfaced as suggestion pills; queries like `coffee` were returning 15 title-LIKE matches instead of the 29 sites tagged `Coffee`. OPL tags are curated editorial labels (effectively "features of a site"), so they should filter results, not just suggest. One-off experimental tags are gated by the threshold to avoid over-narrowing. ## 2026-04-30 (later) ### Added - **`tuned_destination` field on `/v1/search` responses** — admin-curated routing override. Non-null when a configured tuning rule matches the query (e.g. `framer template` → `/framer-templates`). Clients should render this as a prominent banner above `matched_filters`. Rules are stored in WordPress (`wp_options('opl_search_tuning_rules')`); opl-api reads them via SQL with a 60s cache. Match logic is prefix-token-subset: every rule token must be a prefix of some query token, order-independent. - New OpenAPI schema: `TunedDestination`. `SearchResponse` updated to include the field. ## 2026-04-30 ### Added - `GET /v1/search` — free-text search with grouped results (`matched_filters`, `matching_terms`, `matching_authors`, `results.items`). Tokenises the query, AND-matches against taxonomy slugs, falls back to title-LIKE if no taxonomy matches. Cache-Control: public, max-age=300. - **`url` field on every taxonomy term** in `/v1/search` and `/v1/taxonomies` responses. Built from the WordPress `register_taxonomy()` rewrite slug — single source of truth for archive URLs. Notable: `sections` resolves to `/section/` (singular) and `authors` to `/authors/` (distinct from WP's built-in `/author//` user archive). Consumers should use `term.url` directly rather than constructing from `taxonomy` + `slug`. - New OpenAPI schemas: `SearchResponse`, `SearchTermSuggestion`, `SearchResultItem`. New `Search` tag. - New `'admin'` plan tier on `opl_api_keys.plan` ENUM. Admin keys bypass `apiLimiter` — used by internal server-to-server consumers (the `opl-site-search` user that powers onepagelove.com search) so all visitors share one bucket without per-key throttling. ### Changed - `GET /v1/taxonomies` term shape: now includes `url`. Existing fields unchanged (additive). ### Fixed - The `registerLimiter` was applied to every `/v1` route via `app.use('/v1', registerLimiter, authRoutes)` — meaning a 5/hour register limit was throttling every authenticated endpoint. Scoped to `POST /v1/register` only. ## 2026-04-18 ### Added - `GET /v1/meta` — public, unauthenticated endpoint returning current catalog counts (inspirations live/offline, per-taxonomy term counts, last-updated timestamp). Cached for 1 hour. - `docs/changelog.md` — this file. - `docs/terms.md` — published terms of use covering permitted personal/commercial use, the AI training license path, and the prohibition on redistributing the catalog as a competing gallery. - Nightly regen of docs (`llms.txt`, `llms-full.txt`, `index.html`, `openapi.json`) from the live catalog count — numbers in docs now track the database. ### Changed - Replaced the "20,000+" catalog size claim in all docs with accurate hand-curated counts sourced live from the database. - De-emphasised the `authors` query parameter in docs: still accepted, but not promoted as a primary filter — it is intended as attribution metadata in responses. ## 2026-04-17 ### Added - `GET /` returns the API service-discovery JSON for tools and redirects browsers to `/docs` (302 based on `Accept` header). ## 2026-04-16 ### Added - `/docs` — interactive API reference (Scalar) backed by `/docs/openapi.json`. - `/docs/llms.txt` and `/docs/llms-full.txt` — LLM-ingestible overview + full reference. ### Changed - Offline posts (sites 10+ years old, auto-marked offline) now return `url: null` in `GET /v1/inspirations` and `/v1/sections` responses. Screenshots and taxonomy are still served. ## 2026-04-15 ### Added - `GET /v1/sections` — cropped screenshots of specific page sections (hero, pricing table, testimonials, FAQ, etc.) with parent-site metadata. Section crops are generated on-the-fly via Cloudflare Image Resizing `trim`. ### Changed - Screenshot payload now includes `mobile` (if captured) plus explicit `width` / `height` pixel dimensions. ### Fixed - Screenshot meta key lookup corrected in inspiration responses. ## 2026-04-14 ### Fixed - Inspiration query LIMIT / OFFSET parameter binding corrected (use `query` not `execute` for integer positional args in `mysql2`). - `/v1/me` auth routing now resolves correctly against the shared `requireAuth` middleware. ## 2026-04-13 ### Fixed - Rate limiter IPv6 validation — trust proxy configured, `/v1/register` hourly cap now evaluates correctly behind nginx-proxy. - Redis dropped from the rate-limit path — FlyWP's ACL restricts the commands `rate-limit-redis` needs. In-memory limiter is sufficient for single-container MVP. ## 2026-04-12 ### Added - Initial public release: `v0.1.0`. - `POST /v1/register` — email-scoped API key registration, max 2 keys per email, returned once. - `GET /v1/me` — authenticated key info + usage counters. - `GET /v1/inspirations` — catalog search with taxonomy filters, pagination, field selection. - `GET /v1/inspirations/{slug}` — single inspiration lookup. - `GET /v1/taxonomies` — every filter term across every taxonomy. - `GET /v1/stats/taxonomy` — aggregate term usage by time period. - Bearer-token auth with per-key salted SHA-256 hashing + constant-time comparison. - 100 req / 60s rate limiting on authenticated endpoints; 5 registrations / hour per IP.