MR-5: Metered Paywall - #15
Conversation
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds configurable metering rules, sampled-view tracking, admin editing surfaces, a metering countdown block, and per-post exemption handling across front-end and admin paths. ChangesMetering Feature Implementation
Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.php`:
- Around line 30-31: The sanitizer currently coerces missing limits to 0 by
using $input['anonymous_limit'] ?? 0 and $input['registered_limit'] ?? 0 which
can prematurely trip meters; update the logic in the sanitizer to fall back to
the provided $defaults (e.g. $defaults['anonymous_limit'] and
$defaults['registered_limit']) before applying absint and min, so compute
$anonymous_limit = absint($input['anonymous_limit'] ??
$defaults['anonymous_limit']) and $registered_limit =
absint($input['registered_limit'] ?? $defaults['registered_limit']) and then set
$clean['anonymous_limit'] = min($anonymous_limit,
Memberful_Metering_Storage::MAX_VIEWS) and $clean['registered_limit'] =
min($registered_limit, Memberful_Metering_Storage::MAX_VIEWS).
In `@wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css`:
- Around line 170-172: The section comment
"/*--------------------------------------------------------- Metering
------------------------------------------------------------ */" violates
Stylelint's comment-whitespace-inside; update the comment in admin.css (the
Metering section header) to include a space after the opening /* (e.g. "/*
---------------------------------------------------------") so there is
whitespace inside the comment delimiters and the linter rule passes.
In `@wordpress/wp-content/plugins/memberful-wp/views/option_tabs.php`:
- Around line 18-22: The translated tab title for the 'metering' tab is missing
the plugin text domain; update the array entry with id 'metering' so the 'title'
uses the plugin domain (use __('Metering', 'memberful')) instead of __(
'Metering' ) to ensure proper translation loading in Memberful's locale
files—modify the 'title' value in the array where id => 'metering' and leave the
rest unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 13cf76e1-dd64-4067-8296-8dbb280c28c5
📒 Files selected for processing (23)
wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/block.jsonwordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/edit.jswordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/index.jswordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/render.phpwordpress/wp-content/plugins/memberful-wp/js/src/editor-scripts.jswordpress/wp-content/plugins/memberful-wp/js/src/metering-admin.jswordpress/wp-content/plugins/memberful-wp/memberful-wp.phpwordpress/wp-content/plugins/memberful-wp/src/admin.phpwordpress/wp-content/plugins/memberful-wp/src/block-editor.phpwordpress/wp-content/plugins/memberful-wp/src/content_filter.phpwordpress/wp-content/plugins/memberful-wp/src/metering.phpwordpress/wp-content/plugins/memberful-wp/src/metering/access.phpwordpress/wp-content/plugins/memberful-wp/src/metering/config.phpwordpress/wp-content/plugins/memberful-wp/src/metering/metabox.phpwordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.phpwordpress/wp-content/plugins/memberful-wp/src/metering/storage.phpwordpress/wp-content/plugins/memberful-wp/src/options.phpwordpress/wp-content/plugins/memberful-wp/src/urls.phpwordpress/wp-content/plugins/memberful-wp/stylesheets/admin.csswordpress/wp-content/plugins/memberful-wp/views/metering/metabox.phpwordpress/wp-content/plugins/memberful-wp/views/metering/settings.phpwordpress/wp-content/plugins/memberful-wp/views/option_tabs.phpwordpress/wp-content/plugins/memberful-wp/webpack.config.js
…b-view-shortcode MR-33: Add subscription view shortcode
The debug tool renders all mapped and unmapped users. This is an issue on sites with many members because rendering the debug output fails. This commit fixes it by removing the mapping info from the debug output, and adding an option to filter user/member mappings by either of email, `member_id`, or `wp_user_id`. In the UI this is a separate tab, so the debug tool is now split into system info and a member/user lookup. Over the debug endpoint the same `debug_tab` parameter selects the section, defaulting to a usage page that documents the available parameters. Other small improvements: - sensitive information is masked in system info and the error log - error logs are HTML-escaped - individual errors in the error log are separated so it's easier to scan https://3.basecamp.com/3293071/buckets/9856127/todos/10000472282
…l-divider MR-35: Paywall divider is misaligned
…protection MR-34: Block protection styling needs a margin fix
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)
305-309: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid repeated term queries for taxonomy conditions.
condition_matches()can callterm_slugs_for_post()multiple times per request for the same post/taxonomy, causing redundantwp_get_post_terms()lookups.private static function term_slugs_for_post( int $post_id, string $taxonomy ): array { static $cache = array(); $cache_key = $post_id . ':' . $taxonomy; if ( isset( $cache[ $cache_key ] ) ) { return $cache[ $cache_key ]; } $terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'all' ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { $cache[ $cache_key ] = array(); return $cache[ $cache_key ]; } $result = array(); foreach ( $terms as $term ) { $result[] = strtolower( $term->slug ); $result[] = strtolower( $term->name ); } $cache[ $cache_key ] = array_values( array_unique( $result ) ); return $cache[ $cache_key ]; }Also applies to: 346-359
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php` around lines 305 - 309, The term_slugs_for_post() method performs redundant wp_get_post_terms() lookups when called multiple times for the same post and taxonomy combination within a single request. Implement a static cache within the term_slugs_for_post() method using a cache key combining post_id and taxonomy (e.g., "post_id:taxonomy"). At the start of the method, check if the result already exists in this static cache and return it immediately if found. If not cached, proceed with the wp_get_post_terms() call, then store the processed result (including both lowercase slugs and names) in the static cache before returning it. This eliminates redundant database queries for the same post/taxonomy combinations across multiple condition evaluations in a single request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 305-309: The term_slugs_for_post() method performs redundant
wp_get_post_terms() lookups when called multiple times for the same post and
taxonomy combination within a single request. Implement a static cache within
the term_slugs_for_post() method using a cache key combining post_id and
taxonomy (e.g., "post_id:taxonomy"). At the start of the method, check if the
result already exists in this static cache and return it immediately if found.
If not cached, proceed with the wp_get_post_terms() call, then store the
processed result (including both lowercase slugs and names) in the static cache
before returning it. This eliminates redundant database queries for the same
post/taxonomy combinations across multiple condition evaluations in a single
request.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 73b27091-dc92-486f-a075-97f4e09e0940
📒 Files selected for processing (11)
wordpress/wp-content/plugins/memberful-wp/js/src/metering-admin.jswordpress/wp-content/plugins/memberful-wp/src/admin.phpwordpress/wp-content/plugins/memberful-wp/src/metering/access.phpwordpress/wp-content/plugins/memberful-wp/src/metering/config.phpwordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.phpwordpress/wp-content/plugins/memberful-wp/stylesheets/admin.csswordpress/wp-content/plugins/memberful-wp/views/metering/chip.phpwordpress/wp-content/plugins/memberful-wp/views/metering/condition.phpwordpress/wp-content/plugins/memberful-wp/views/metering/group.phpwordpress/wp-content/plugins/memberful-wp/views/metering/icon-remove.phpwordpress/wp-content/plugins/memberful-wp/views/metering/settings.php
…pdates MR-29: Copy update to gutenberg visibility settings
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php (1)
24-25: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winUse the scope-specific fallback match.
$matchfalls back to'all'for both scopes, but newexclude_rulesgroups default to'any'later in this file. If saved data is missing or carries an invalidmatch, reopening and saving the form will silently tighten exception groups and meter posts that should stay excluded.$conditions = isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array(); $default_match = 'exclude_rules' === $scope ? 'any' : 'all'; $match = isset( $group['match'] ) && in_array( $group['match'], Memberful_Metering_Config::MATCH_TYPES, true ) ? $group['match'] : $default_match;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php` around lines 24 - 25, The scope-specific default for the group match is not being preserved in the settings form, so invalid or missing values always fall back to "all". Update the logic in settings.php where $conditions and $match are initialized to use a scope-aware fallback based on $scope (keeping exclude_rules on "any" and other scopes on "all"), and make sure the later form-saving/rendering paths use that same default behavior so reopening and saving does not tighten exclusion groups.wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)
325-335: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winMatch URL rules against full permalinks too.
Line 326 strips the permalink to only its path, so an admin-entered full URL like
https://example.com/news/foowill not match/news/foo. That can silently skip metering or exclusion rules configured with copied permalinks.case 'url': $permalink = (string) get_permalink( $post->ID ); $path = wp_parse_url( $permalink, PHP_URL_PATH ); $path = is_string( $path ) ? $path : ''; $matches = false; foreach ( $values as $fragment ) { $fragment = (string) $fragment; if ( '' !== $fragment && ( false !== strpos( $path, $fragment ) || false !== strpos( $permalink, $fragment ) ) ) { $matches = true; break; } } break;As per path instructions, review possible edge cases and unexpected behaviour for PHP/WordPress code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php` around lines 325 - 335, The URL matching logic in the access metering rule currently only checks the parsed path from get_permalink(), so full URLs entered in admin rules will not match. Update the 'url' case in access.php to compare each fragment against both the permalink path and the full permalink string, using the existing get_permalink(), wp_parse_url(), and strpos() flow, so copied URLs like https://example.com/news/foo are matched correctly.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 325-335: The URL matching logic in the access metering rule
currently only checks the parsed path from get_permalink(), so full URLs entered
in admin rules will not match. Update the 'url' case in access.php to compare
each fragment against both the permalink path and the full permalink string,
using the existing get_permalink(), wp_parse_url(), and strpos() flow, so copied
URLs like https://example.com/news/foo are matched correctly.
In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php`:
- Around line 24-25: The scope-specific default for the group match is not being
preserved in the settings form, so invalid or missing values always fall back to
"all". Update the logic in settings.php where $conditions and $match are
initialized to use a scope-aware fallback based on $scope (keeping exclude_rules
on "any" and other scopes on "all"), and make sure the later
form-saving/rendering paths use that same default behavior so reopening and
saving does not tighten exclusion groups.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 943b259b-e4f2-44f0-b016-faa5fdf141c8
📒 Files selected for processing (5)
wordpress/wp-content/plugins/memberful-wp/src/metering/access.phpwordpress/wp-content/plugins/memberful-wp/src/metering/config.phpwordpress/wp-content/plugins/memberful-wp/src/metering/storage.phpwordpress/wp-content/plugins/memberful-wp/stylesheets/admin.csswordpress/wp-content/plugins/memberful-wp/views/metering/settings.php
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 620220b. Configure here.
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](fastify/fast-uri@v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com>
…rn/wordpress/wp-content/plugins/memberful-wp/fast-uri-3.1.5 Bump fast-uri from 3.1.2 to 3.1.5 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/postcss-8.5.25 Bump postcss from 8.5.15 to 8.5.25 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/shell-quote-1.10.0 Bump shell-quote from 1.8.4 to 1.10.0 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/svgo-3.3.4 Bump svgo from 3.3.3 to 3.3.4 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/immutable-5.1.9 Bump immutable from 5.1.6 to 5.1.9 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/axios-1.18.1 Bump axios from 1.16.1 to 1.18.1 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/websocket-driver-0.7.5 Bump websocket-driver from 0.7.4 to 0.7.5 in /wordpress/wp-content/plugins/memberful-wp
…rn/wordpress/wp-content/plugins/memberful-wp/ip-address-10.4.0 Bump ip-address from 10.2.0 to 10.4.0 in /wordpress/wp-content/plugins/memberful-wp
…ywall MR-31: Lite version of paywall
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ragraph-count MR-10: Allow setting paragraph count before paywall
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| $period = absint( $input['period_days'] ?? 0 ); | ||
| $clean['period_days'] = $period > 0 ? $period : $defaults['period_days']; | ||
|
|
||
| $anonymous_limit = absint( $input['anonymous_limit'] ?? $defaults['anonymous_limit'] ); |
There was a problem hiding this comment.
@av3nger an empty limit field saves as 0 and locks every metered post. period_days already falls back to its default.
| * @param WP_Post $post Post being rendered. | ||
| * @return string Rendered content above the divider block. | ||
| */ | ||
| function memberful_wp_content_above_divider_block( WP_Post $post ): string { |
There was a problem hiding this comment.
@av3nger this splits raw block markup on a comment prefix, so a divider nested in a Group or Columns block yields unbalanced HTML. force_balance_tags() on the return may fix this issue if you can test that please.
# Conflicts: # wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css
|
Reworked in #28 |

Summary
This PR adds a metered paywall on top of the existing Memberful paywall: a "free article allowance" that lets anonymous visitors and registered free members read a configurable number of matching posts within a rolling period before the paywall is shown.
What's included
{count}template, shown only while the visitor is still being sampled.Test plan
{count}button, and confirm it shows the remaining count while sampling and disappears once the meter trips.Note
High Risk
Changes who sees full content vs the paywall and persists view counts in cookies/user meta; misconfiguration or edge-cache bypass could leak metered HTML or block legitimate readers.
Overview
Adds a metered paywall on top of Memberful’s existing protection: visitors get a rolling allowance of free reads on matching content before the paywall applies.
Publishers configure it from a new Metering settings tab—enable toggle, period and anonymous vs free-member limits, optional metering of members-only posts, and include/except rule groups (post type, category/tag, URL) built with new admin JS. Views are stored in a signed anonymous cookie or user meta, merged on login; metered responses set no-cache headers.
the_contentnow respects a per-request metering decision only for the singular post under view: samples render in full; a tripped meter forces the paywall even when normal ACL might differ. The paywall primary CTA can switch to free registration when a logged-out visitor hits the limit and registered users get a higher allowance.Editors get a metering countdown block (
{count}templates) and a per-post exempt from metering metabox.Reviewed by Cursor Bugbot for commit 6f07859. Bugbot is set up for automated code reviews on this repo. Configure here.