diff --git a/.wordpress-org/screenshot-1.png b/.wordpress-org/screenshot-1.png index 02848ec..e8e0d24 100644 Binary files a/.wordpress-org/screenshot-1.png and b/.wordpress-org/screenshot-1.png differ diff --git a/.wordpress-org/screenshot-2.png b/.wordpress-org/screenshot-2.png new file mode 100644 index 0000000..597b7c1 Binary files /dev/null and b/.wordpress-org/screenshot-2.png differ diff --git a/.wordpress-org/screenshot-3.png b/.wordpress-org/screenshot-3.png new file mode 100644 index 0000000..e5a8ace Binary files /dev/null and b/.wordpress-org/screenshot-3.png differ diff --git a/.wordpress-org/screenshot-4.png b/.wordpress-org/screenshot-4.png new file mode 100644 index 0000000..e46a688 Binary files /dev/null and b/.wordpress-org/screenshot-4.png differ diff --git a/.wordpress-org/screenshot-5.png b/.wordpress-org/screenshot-5.png new file mode 100644 index 0000000..4e911bc Binary files /dev/null and b/.wordpress-org/screenshot-5.png differ diff --git a/README.md b/README.md index 7d35e6d..56c069f 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,93 @@ # Freemius for WordPress Contributors: freemius, xaverb -Tags: freemius, checkout, payment, button +Tags: freemius checkout, pricing table, block editor, customer portal, payment button +Requires at least: 6.6 Tested up to: 6.9 Stable tag: 0.4.2 License: MIT -Freemius for WordPress helps you to add Freemius Checkout to any button of your WordPress content using the block editor. +Add Freemius checkout, pricing tables, and a Customer Portal to WordPress with the Block Editor—no custom code required. ## Description -Freemius for WordPress is a powerful WordPress plugin that allows you to transform any block-based button into a Freemius checkout button. This makes it easy to integrate Freemius payment processing into your WordPress site with minimal effort. +Freemius for WordPress lets you sell Freemius products from the Block Editor (Gutenberg). Turn any Button into a Freemius checkout, build dynamic pricing tables with scopes and modifiers, manage multiple products in settings, and embed the Freemius Customer Portal so customers can manage licenses, billing, and downloads—without JavaScript, theme hacks, or custom coding. - +**Not using the Block Editor?** See the [overlay checkout](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/) or [hosted checkout](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-hosted-page/) in the Freemius docs. ### Key features -- Convert any block button to a Freemius checkout button -- Seamless integration with Freemius payment processing -- Customizable checkout experience -- Works with all block-based themes and plugins +- Native Block Editor support: Freemius checkout on Button blocks, with preview in the editor +- Dynamic pricing tables: plan columns, field mapping (price, title, description), and checkout CTAs +- Scope modifiers: currency, billing cycle, and license toggles on the page +- Multi-product settings: connect Product IDs and API tokens under Settings → Freemius +- Freemius Customer Portal block: embed the members dashboard (Store ID + Public Key) +- Compatible with modern block themes and classic themes that support the block editor +- Free and open source -### Roadmap +### Try it -- Support for Pricing tables. -- Better support to measure analytics. -- Dedicated Settings page. -- Testimonials (from the API). +- [WordPress Playground demo](https://playground.wordpress.net/?plugin=freemius) +- [Plugin documentation on GitHub](https://github.com/Freemius/freemius-wp-plugin/tree/main/docs) + +https://www.youtube.com/watch?v=MTOuIBGan7E ## Installation -1. Upload the plugin files to the `/wp-content/plugins/freemius` directory, or install the plugin through the WordPress plugins screen directly -2. Activate the plugin through the 'Plugins' screen in WordPress -3. Place a button via the block editor into your page/post -4. Enable Freemius checkout in the button settings -5. Configure your product details and the button will automatically handle the checkout process. +1. Install and activate **Freemius for WordPress** from Plugins → Add New, or upload the plugin zip. +2. Open **Settings → Freemius** and add your product under **Products** (Product ID and Token from the [Freemius Developer Dashboard](https://dashboard.freemius.com/)). +3. Optionally set site-wide defaults under **Editor Settings**. +4. Add a **Button** block, enable **Freemius Checkout** in the Freemius panel, and use **Preview** before publishing. +5. For a pricing page: use Group/Columns with Freemius scopes, map plan fields, and add checkout buttons per plan. +6. For the Customer Portal: add the **Freemius Customer Portal** block and enter your **Store ID** and **Public Key**. ## Frequently Asked Questions ### How do I set up a Freemius checkout button? -Simply add a button block to your page or post, then enable the Freemius checkout option in the button settings. Configure your product details and the button will automatically handle the checkout process. +Add a **Button** block, turn on **Freemius Checkout** in the Freemius panel, and connect your product under **Settings → Freemius**. See the [getting started guide](https://github.com/Freemius/freemius-wp-plugin/blob/main/docs/getting-started.md) and [button docs](https://github.com/Freemius/freemius-wp-plugin/blob/main/docs/button.md). + +### How do I build a Freemius pricing table? + +Create a page with a scoped Group and one Column per plan, map price and plan fields, then add a checkout Button in each column. Details: [Creating your Pricing page](https://github.com/Freemius/freemius-wp-plugin/blob/main/docs/creating-your-pricing-page.md). + +### How do I embed the Freemius Customer Portal? + +Add the **Freemius Customer Portal** block and enter your **Store ID** and **Public Key** from the Freemius Developer Dashboard (Stores → Settings → Keys). See the [Customer Portal guide](https://github.com/Freemius/freemius-wp-plugin/blob/main/docs/customer-portal.md) and [Freemius portal documentation](https://freemius.com/help/documentation/users-account-management/embedding-customer-portal/). ### Can I customize the checkout experience? -Yes, you can customize various aspects of the checkout process through the plugin settings, including product details, pricing, and checkout flow. +Yes. Configure product details and site-wide defaults under **Settings → Freemius**, and adjust per-button Freemius options (plan, licenses, callbacks, and more) in the block sidebar. ### Is this plugin compatible with my theme? -The plugin works with any WordPress theme that supports the block editor (Gutenberg). It's designed to be compatible with all modern WordPress themes and plugins. +The plugin targets the Block Editor (Gutenberg). It works with block themes and classic themes that support the block editor. -### Where do I find the offical Freemius documentation? +### Where is pricing data loaded from? + +Mapped prices and plan copy are saved when you edit and publish a page; the frontend does not call the Freemius API on every visit. After you change prices in the Freemius dashboard, reopen the page in the editor and **Update** it. + +### Where do I find the official Freemius documentation? [Freemius Documentation](https://freemius.com/help/documentation/) ## Screenshots -1. Button block settings with Freemius checkout option -2. Example of a Freemius checkout button in action +1. Freemius checkout preview in the block editor +2. Freemius scope modifiers on a pricing page +3. Published Freemius pricing page +4. Freemius Customer Portal for licenses, billing, and downloads +5. Freemius Products settings tab + ## Changelog +## [Unreleased] + +- added: Freemius Customer Portal block to embed the members dashboard on your site +- improved: portal block height adapts to iframe content with viewport-based limits instead of a fixed pixel size on the frontend +- improved: portal block editor preview height supports px, vh, vw, rem, em, and % units + ## [0.4.2] - fixed: missing import for MappingSettings diff --git a/screenshots/README.md b/screenshots/README.md index f12c504..f7e53b5 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -4,16 +4,17 @@ Playwright and the `/screenshot` Cursor command store PNGs here. The tree is **g ## Manifest -[`image-manifest.json`](image-manifest.json) lists all images: titles, descriptions, `use` type, optional Playwright `capture` URLs, and where each image belongs in docs. +[`image-manifest.json`](image-manifest.json) lists all images: titles, descriptions, `use` type, optional Playwright `capture` URLs, and where each image belongs in docs or WordPress.org assets. | `use` | Storage | What happens | | ----- | ------- | -------------- | | `docs` | `screenshots/{id}/` + copy under doc `assets/` | Script copies into the doc path in `embed.path` and updates markdown | -| `review` | `screenshots/{id}/` | QA captures; no doc copy | +| `wordpress` | `screenshots/{id}/` and/or `docs/assets/` → `.wordpress-org/screenshot-N.png` | Script copies into `wordpress.path`, sets status, syncs README Screenshots captions | +| `review` | `screenshots/{id}/` | QA captures; no doc / WP.org copy | Register or edit entries in the manifest; do not maintain a separate targets file. -**Manifest-first:** every doc PNG under `docs/**/assets/` and every `![alt](assets/...)` in markdown must have a matching `images[]` entry. Add or update the manifest in the same change set as the asset or markdown edit. +**Manifest-first:** every doc PNG under `docs/**/assets/` and every `![alt](assets/...)` in markdown must have a matching `images[]` entry. Every `.wordpress-org/screenshot-N.png` must have a matching `use: wordpress` entry. Add or update the manifest in the same change set as the asset or markdown edit. ## Layout @@ -30,6 +31,7 @@ Editor captures use **post 428** on `dev.local`. See [`fixture-post-428.md`](fix - Editor: `https://dev.local/wp-admin/post.php?post=428&action=edit` - Frontend playground: `https://dev.local/playground/` +- Customer Portal (WP.org screenshot): `https://dev.local/portal/#!/licenses` Override with `SCREENSHOT_FIXTURE_POST_ID` and `SCREENSHOT_PLAYGROUND_URL`. @@ -46,6 +48,7 @@ npx playwright install chromium npm run update-screenshots npm run update-screenshots -- button-overview npm run update-screenshots -- --force button-overview +npm run update-screenshots -- wporg-customer-portal ``` Or `/update-screenshots` in Cursor. See [`.cursor/skills/update-screenshots/SKILL.md`](../.cursor/skills/update-screenshots/SKILL.md). @@ -53,3 +56,16 @@ Or `/update-screenshots` in Cursor. See [`.cursor/skills/update-screenshots/SKIL Contributor inventory (manifest ids, capture status): [`INVENTORY.md`](INVENTORY.md) (generated by `npm run sync:screenshots-docs`). **Compare thresholds:** `SCREENSHOT_MIN_DIFF_RATIO` (default `0.02`), `SCREENSHOT_ABORT_RATIO` (default `0.98`), `SCREENSHOT_COMPARE_TOLERANCE` (default `2`). Pass `--force` to always write PNGs. + +## WordPress.org entries + +`use: wordpress` entries require a `wordpress` block: + +| Field | Purpose | +| ----- | ------- | +| `from` | `screenshots/{id}/source.png` or an existing `docs/assets/….png` | +| `path` | `.wordpress-org/screenshot-N.png` | +| `caption` | Text for the numbered Screenshots list in root `README.md` | +| `status` | `placeholder` until copied/captured, then `captured` | + +After a successful wordpress update, the script rewrites the `## Screenshots` section in `README.md` from captions ordered by screenshot number. diff --git a/screenshots/image-manifest.json b/screenshots/image-manifest.json index 05f82ba..20beb6c 100644 --- a/screenshots/image-manifest.json +++ b/screenshots/image-manifest.json @@ -412,6 +412,94 @@ "alt": "Products tab on the Freemius settings page", "status": "captured" } + }, + { + "id": "wporg-button-overview", + "title": "WordPress.org: checkout preview in the block editor", + "description": "Plugin directory screenshot of the Freemius checkout preview modal open in the block editor after clicking Preview.", + "use": "wordpress", + "match": [ + "wordpress.org screenshot 1", + "checkout preview", + "preview modal" + ], + "wordpress": { + "from": "docs/assets/docs-homepage-preview.png", + "path": ".wordpress-org/screenshot-1.png", + "caption": "Freemius checkout preview in the block editor", + "status": "captured" + } + }, + { + "id": "wporg-customer-portal", + "title": "WordPress.org: Customer Portal licenses view", + "description": "Plugin directory screenshot of the Freemius Customer Portal embed on the licenses screen.", + "use": "wordpress", + "match": [ + "wordpress.org screenshot 3", + "customer portal", + "licenses" + ], + "capture": { + "url": "https://dev.local/portal/#!/licenses", + "viewports": [ + "desktop" + ] + }, + "wordpress": { + "from": "screenshots/wporg-customer-portal/source.png", + "path": ".wordpress-org/screenshot-4.png", + "caption": "Freemius Customer Portal for licenses, billing, and downloads", + "status": "captured" + } + }, + { + "id": "wporg-pricing-page", + "title": "WordPress.org: published pricing page", + "description": "Plugin directory screenshot of a published Freemius pricing page with plan columns and checkout buttons.", + "use": "wordpress", + "match": [ + "wordpress.org screenshot 2", + "pricing page" + ], + "wordpress": { + "from": "docs/assets/pricing-page-playground.png", + "path": ".wordpress-org/screenshot-3.png", + "caption": "Published Freemius pricing page", + "status": "captured" + } + }, + { + "id": "wporg-scope-modifiers", + "title": "WordPress.org: scope modifiers", + "description": "Plugin directory screenshot of Freemius scope modifiers for currency, billing cycle, and licenses.", + "use": "wordpress", + "match": [ + "wordpress.org screenshot 4", + "scope modifiers" + ], + "wordpress": { + "from": "docs/assets/scope-modifiers.png", + "path": ".wordpress-org/screenshot-2.png", + "caption": "Freemius scope modifiers on a pricing page", + "status": "captured" + } + }, + { + "id": "wporg-settings-products", + "title": "WordPress.org: Products settings tab", + "description": "Plugin directory screenshot of Settings → Freemius Products tab with Product ID and Token fields.", + "use": "wordpress", + "match": [ + "wordpress.org screenshot 5", + "products settings" + ], + "wordpress": { + "from": "docs/assets/settings-products.png", + "path": ".wordpress-org/screenshot-5.png", + "caption": "Freemius Products settings tab", + "status": "captured" + } } ] } diff --git a/screenshots/image-manifest.schema.json b/screenshots/image-manifest.schema.json index 417ae76..fe3fd1d 100644 --- a/screenshots/image-manifest.schema.json +++ b/screenshots/image-manifest.schema.json @@ -55,7 +55,7 @@ }, "useType": { "type": "string", - "enum": [ "docs", "website", "review" ] + "enum": [ "docs", "wordpress", "review" ] }, "embedStatus": { "type": "string", @@ -123,6 +123,29 @@ "status": { "$ref": "#/$defs/embedStatus" } } }, + "wordpressBlock": { + "type": "object", + "additionalProperties": false, + "required": [ "from", "path", "caption", "status" ], + "properties": { + "from": { + "type": "string", + "pattern": "^(screenshots/[a-z0-9]+(-[a-z0-9]+)*/[^/]+\\.png|docs/assets/[^/]+\\.(png|jpg|jpeg|webp|gif))$", + "description": "Source under screenshots/{id}/ or a committed docs/assets file." + }, + "path": { + "type": "string", + "pattern": "^\\.wordpress-org/screenshot-[0-9]+\\.png$", + "description": "WordPress.org plugin directory screenshot path." + }, + "caption": { + "type": "string", + "minLength": 1, + "description": "README Screenshots section caption (numbered list text)." + }, + "status": { "$ref": "#/$defs/embedStatus" } + } + }, "imageEntry": { "type": "object", "additionalProperties": false, @@ -137,7 +160,8 @@ "items": { "type": "string", "minLength": 1 } }, "capture": { "$ref": "#/$defs/captureBlock" }, - "embed": { "$ref": "#/$defs/embedBlock" } + "embed": { "$ref": "#/$defs/embedBlock" }, + "wordpress": { "$ref": "#/$defs/wordpressBlock" } }, "allOf": [ { @@ -145,14 +169,34 @@ "properties": { "use": { "const": "docs" } }, "required": [ "use" ] }, - "then": { "required": [ "embed" ] } + "then": { + "required": [ "embed" ], + "not": { "required": [ "wordpress" ] } + } + }, + { + "if": { + "properties": { "use": { "const": "wordpress" } }, + "required": [ "use" ] + }, + "then": { + "required": [ "wordpress" ], + "not": { "required": [ "embed" ] } + } }, { "if": { "properties": { "use": { "const": "review" } }, "required": [ "use" ] }, - "then": { "not": { "required": [ "embed" ] } } + "then": { + "not": { + "anyOf": [ + { "required": [ "embed" ] }, + { "required": [ "wordpress" ] } + ] + } + } } ] } diff --git a/scripts/update-doc-screenshots.mjs b/scripts/update-doc-screenshots.mjs index f57f9b2..c42de58 100644 --- a/scripts/update-doc-screenshots.mjs +++ b/scripts/update-doc-screenshots.mjs @@ -67,9 +67,11 @@ const ENTRY_KEY_ORDER = [ 'match', 'capture', 'embed', - 'website', + 'wordpress', ]; +const README_PATH = resolve( rootDir, 'README.md' ); + /** * @param {string} dir */ @@ -677,9 +679,12 @@ function isEditorCanvasCapture( entry ) { * @param {{ id: string, capture: { url: string } }} entry */ function isFrontendCapture( entry ) { + const url = entry.capture?.url ?? ''; return ( entry.id.startsWith( 'pricing-page-' ) || - entry.capture.url.includes( '/playground' ) + entry.id.startsWith( 'wporg-customer-portal' ) || + url.includes( '/playground' ) || + url.includes( '/portal' ) ); } @@ -3616,6 +3621,274 @@ function reviewSourcePath( id ) { return `screenshots/${ id }/source.png`; } +/** + * @param {string} path + * @returns {number | null} + */ +function wordpressScreenshotNumber( path ) { + const match = path.match( /\.wordpress-org\/screenshot-(\d+)\.png$/ ); + return match ? Number( match[ 1 ] ) : null; +} + +/** + * Rewrite README.md Screenshots section from wordpress manifest captions. + * + * @param {Array<{ wordpress: { path: string, caption: string } }>} wordpressEntries + */ +function syncReadmeScreenshotCaptions( wordpressEntries ) { + const ordered = [ ...wordpressEntries ] + .filter( ( entry ) => wordpressScreenshotNumber( entry.wordpress.path ) ) + .sort( + ( a, b ) => + wordpressScreenshotNumber( a.wordpress.path ) - + wordpressScreenshotNumber( b.wordpress.path ) + ); + + if ( ordered.length === 0 ) { + return; + } + + const captions = ordered + .map( + ( entry, index ) => + `${ index + 1 }. ${ entry.wordpress.caption }` + ) + .join( '\n' ); + + const readme = readFileSync( README_PATH, 'utf8' ); + const sectionPattern = + /(## Screenshots\n\n)([\s\S]*?)(?=\n## |\n*$)/; + + if ( ! sectionPattern.test( readme ) ) { + throw new Error( + 'README.md is missing a ## Screenshots section to sync captions into' + ); + } + + const next = readme.replace( sectionPattern, `$1${ captions }\n` ); + writeFileSync( README_PATH, next, 'utf8' ); +} + +/** + * @param {import('playwright').BrowserType} chromium + * @param {object} manifest + * @param {Array<{ id: string, capture?: object, wordpress: { from: string, path: string, caption: string, status: string } }>} entries + * @param {{ force?: boolean }} options + */ +async function processWordpressEntries( + chromium, + manifest, + entries, + options = {} +) { + const compareOptions = getScreenshotCompareOptions(); + if ( options.force ) { + compareOptions.disabled = true; + } + + /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ + const results = []; + let manifestDirty = false; + let browser = null; + let page = null; + + const needsBrowser = entries.some( ( entry ) => entry.capture ); + + if ( needsBrowser ) { + const probeEntry = entries.find( ( entry ) => entry.capture ); + const probeUrl = resolveCaptureUrl( probeEntry.capture.url ); + console.log( ` Checking ${ probeUrl }…` ); + + try { + await assertSiteReachable( chromium, probeUrl ); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + console.error( + `Local site not reachable at ${ probeUrl } — start dev.local and ensure auto-login works.` + ); + console.error( String( error ) ); + process.exit( 1 ); + } + + try { + browser = await chromium.launch(); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + throw error; + } + + page = await browser.newPage( { ignoreHTTPSErrors: true } ); + } + + try { + for ( let index = 0; index < entries.length; index += 1 ) { + const entry = entries[ index ]; + const linePrefix = ` [${ index + 1 }/${ entries.length }] WordPress.org ${ entry.id }…`; + const url = entry.capture + ? resolveCaptureUrl( entry.capture.url ) + : entry.wordpress.from; + + try { + if ( entry.capture ) { + const sourcePath = entry.wordpress.from.startsWith( + 'screenshots/' + ) + ? entry.wordpress.from + : reviewSourcePath( entry.id ); + const tempCapturePath = `${ sourcePath }.capture-tmp.png`; + + await captureScreenshot( + page, + entry, + manifest.viewports, + tempCapturePath + ); + + const baselinePath = existsSync( + resolve( rootDir, entry.wordpress.path ) + ) + ? entry.wordpress.path + : existsSync( resolve( rootDir, sourcePath ) ) + ? sourcePath + : null; + + const entryCompareOptions = + resolveCompareOptionsForCapture( + compareOptions, + entry.capture + ); + const compareResult = baselinePath + ? await compareScreenshotFiles( + resolve( rootDir, baselinePath ), + resolve( rootDir, tempCapturePath ), + entryCompareOptions + ) + : { + action: 'update', + diffRatio: 1, + reason: 'no baseline file', + }; + + const tempCaptureAbs = resolve( rootDir, tempCapturePath ); + + if ( compareResult.action === 'skip' ) { + unlinkSync( tempCaptureAbs ); + results.push( { + id: entry.id, + url, + output: entry.wordpress.path, + status: 'unchanged', + detail: compareResult.reason, + } ); + logCaptureLine( linePrefix, 'skipped' ); + continue; + } + + if ( compareResult.action === 'abort' ) { + unlinkSync( tempCaptureAbs ); + results.push( { + id: entry.id, + url, + output: entry.wordpress.path, + status: 'error', + detail: `Screenshot comparison aborted: ${ compareResult.reason }`, + } ); + logCaptureLine( linePrefix, 'error' ); + continue; + } + + copyAsset( tempCapturePath, sourcePath ); + unlinkSync( tempCaptureAbs ); + copyAsset( sourcePath, entry.wordpress.path ); + } else { + if ( ! existsSync( resolve( rootDir, entry.wordpress.from ) ) ) { + throw new Error( + `Missing wordpress.from source: ${ entry.wordpress.from }` + ); + } + copyAsset( entry.wordpress.from, entry.wordpress.path ); + } + + entry.wordpress.status = 'captured'; + manifestDirty = true; + results.push( { + id: entry.id, + url, + output: entry.wordpress.path, + status: 'ok', + } ); + logCaptureLine( linePrefix, 'ok' ); + } catch ( error ) { + results.push( { + id: entry.id, + url, + output: entry.wordpress.path, + status: 'error', + detail: String( error ), + } ); + logCaptureLine( linePrefix, 'error' ); + } + } + } finally { + if ( browser ) { + await browser.close(); + } + } + + if ( manifestDirty ) { + writeManifest( manifest ); + const allWordpress = manifest.images.filter( + ( entry ) => entry.use === 'wordpress' && entry.wordpress + ); + syncReadmeScreenshotCaptions( allWordpress ); + } + + results.sort( ( a, b ) => a.id.localeCompare( b.id ) ); + + const failures = results.filter( ( row ) => row.status === 'error' ); + const unchanged = results.filter( ( row ) => row.status === 'unchanged' ); + const updated = results.filter( ( row ) => row.status === 'ok' ); + + if ( failures.length === 0 ) { + const parts = []; + if ( updated.length > 0 ) { + parts.push( `${ updated.length } updated` ); + } + if ( unchanged.length > 0 ) { + parts.push( `${ unchanged.length } unchanged` ); + } + console.log( + `Ready.. (${ parts.join( ', ' ) || 'no wordpress captures' })` + ); + } else { + console.log( + `Ready.. (${ updated.length } updated, ${ unchanged.length } unchanged, ${ failures.length } failed)` + ); + } + + console.log( '\nWordPress.org screenshot summary:\n' ); + console.log( + '| id | url | output | status |', + '\n| --- | --- | --- | --- |' + ); + for ( const row of results ) { + const detail = row.detail ? ` (${ row.detail })` : ''; + console.log( + `| ${ row.id } | ${ row.url } | ${ row.output } | ${ row.status }${ detail } |` + ); + } + + if ( failures.length > 0 ) { + process.exit( 1 ); + } +} + /** * @param {string[]} argv * @returns {{ filterId: string | null, help: boolean, force: boolean }} @@ -3665,10 +3938,16 @@ function parseCliArgs( argv ) { /** * @param {Array<{ id: string }>} captureEntries * @param {Array<{ id: string }>} reviewCaptureEntries + * @param {Array<{ id: string }>} wordpressEntries */ -function printUsage( captureEntries, reviewCaptureEntries = [] ) { +function printUsage( + captureEntries, + reviewCaptureEntries = [], + wordpressEntries = [] +) { const ids = captureEntries.map( ( entry ) => entry.id ).sort(); const reviewIds = reviewCaptureEntries.map( ( entry ) => entry.id ).sort(); + const wordpressIds = wordpressEntries.map( ( entry ) => entry.id ).sort(); console.log( `Usage: npm run update-screenshots @@ -3684,6 +3963,13 @@ ${ }):\n${ reviewIds.map( ( id ) => ` - ${ id }` ).join( '\n' ) }` : '' } +${ + wordpressIds.length + ? `\nWordPress.org ids (${ wordpressIds.length }):\n${ wordpressIds + .map( ( id ) => ` - ${ id }` ) + .join( '\n' ) }` + : '' +} ` ); } @@ -3789,9 +4075,16 @@ async function main() { const allReviewCaptureEntries = manifest.images.filter( ( entry ) => entry.use === 'review' && entry.capture ); + const allWordpressEntries = manifest.images.filter( + ( entry ) => entry.use === 'wordpress' && entry.wordpress + ); if ( help ) { - printUsage( allCaptureEntries, allReviewCaptureEntries ); + printUsage( + allCaptureEntries, + allReviewCaptureEntries, + allWordpressEntries + ); process.exit( 0 ); } @@ -3800,23 +4093,49 @@ async function main() { if ( ! entry ) { console.error( `Unknown manifest id "${ filterId }".` ); - printUsage( allCaptureEntries, allReviewCaptureEntries ); - process.exit( 1 ); - } - - if ( ! entry.capture ) { - console.error( - `"${ filterId }" has no capture block — add capture.url and capture.viewports first.` + printUsage( + allCaptureEntries, + allReviewCaptureEntries, + allWordpressEntries ); process.exit( 1 ); } if ( entry.use === 'review' ) { + if ( ! entry.capture ) { + console.error( + `"${ filterId }" has no capture block — add capture.url and capture.viewports first.` + ); + process.exit( 1 ); + } console.log( `Starting.. (1 review screenshot: ${ filterId })` ); await captureReviewEntry( chromium, manifest, entry ); return; } + if ( entry.use === 'wordpress' ) { + if ( ! entry.wordpress ) { + console.error( + `"${ filterId }" is missing the wordpress block (from, path, caption, status).` + ); + process.exit( 1 ); + } + console.log( + `Starting.. (1 WordPress.org screenshot: ${ filterId })` + ); + await processWordpressEntries( chromium, manifest, [ entry ], { + force, + } ); + return; + } + + if ( ! entry.capture ) { + console.error( + `"${ filterId }" has no capture block — add capture.url and capture.viewports first.` + ); + process.exit( 1 ); + } + if ( entry.use !== 'docs' || ! entry.embed ) { console.error( `"${ filterId }" is not a docs embed entry (use: docs with embed block required).` @@ -3825,9 +4144,9 @@ async function main() { } } - if ( allCaptureEntries.length === 0 ) { + if ( allCaptureEntries.length === 0 && allWordpressEntries.length === 0 ) { console.error( - 'No docs manifest entries with capture blocks — add capture.url and capture.viewports first.' + 'No docs or wordpress manifest entries to process — add capture/wordpress blocks first.' ); process.exit( 1 ); } @@ -3841,253 +4160,267 @@ async function main() { captureEntries = [ entry ]; } - const captureCount = captureEntries.length; - console.log( - filterId - ? `Starting.. (1 screenshot: ${ filterId })` - : `Starting.. (${ captureCount } screenshots)` - ); + if ( captureEntries.length > 0 ) { + const captureCount = captureEntries.length; + console.log( + filterId + ? `Starting.. (1 screenshot: ${ filterId })` + : `Starting.. (${ captureCount } screenshots)` + ); - const probeUrl = resolveCaptureUrl( captureEntries[ 0 ].capture.url ); - console.log( ` Checking ${ probeUrl }…` ); - const compareOptions = getScreenshotCompareOptions(); - if ( force ) { - compareOptions.disabled = true; - } + const probeUrl = resolveCaptureUrl( captureEntries[ 0 ].capture.url ); + console.log( ` Checking ${ probeUrl }…` ); + const compareOptions = getScreenshotCompareOptions(); + if ( force ) { + compareOptions.disabled = true; + } - try { - await assertSiteReachable( chromium, probeUrl ); - } catch ( error ) { - if ( isMissingBrowserError( error ) ) { - printPlaywrightHelp(); + try { + await assertSiteReachable( chromium, probeUrl ); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + console.error( + `Local site not reachable at ${ probeUrl } — start dev.local and ensure auto-login works.` + ); + console.error( String( error ) ); process.exit( 1 ); } - console.error( - `Local site not reachable at ${ probeUrl } — start dev.local and ensure auto-login works.` - ); - console.error( String( error ) ); - process.exit( 1 ); - } - /** @type {Map} */ - const bySource = new Map(); - for ( const entry of docsEntries ) { - const key = entry.embed.from; - if ( ! bySource.has( key ) ) { - bySource.set( key, [] ); + /** @type {Map} */ + const bySource = new Map(); + for ( const entry of docsEntries ) { + const key = entry.embed.from; + if ( ! bySource.has( key ) ) { + bySource.set( key, [] ); + } + bySource.get( key ).push( entry ); } - bySource.get( key ).push( entry ); - } - /** @type {Map} */ - const captureBySource = new Map(); - for ( const entry of captureEntries ) { - if ( filterId ) { - captureBySource.set( entry.embed.from, entry ); - continue; + /** @type {Map} */ + const captureBySource = new Map(); + for ( const entry of captureEntries ) { + if ( filterId ) { + captureBySource.set( entry.embed.from, entry ); + continue; + } + + if ( ! captureBySource.has( entry.embed.from ) ) { + captureBySource.set( entry.embed.from, entry ); + } } - if ( ! captureBySource.has( entry.embed.from ) ) { - captureBySource.set( entry.embed.from, entry ); + /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ + const results = []; + let manifestDirty = false; + + let browser; + try { + browser = await chromium.launch(); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + throw error; } - } - /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ - const results = []; - let manifestDirty = false; + const page = await browser.newPage( { ignoreHTTPSErrors: true } ); - let browser; - try { - browser = await chromium.launch(); - } catch ( error ) { - if ( isMissingBrowserError( error ) ) { - printPlaywrightHelp(); - process.exit( 1 ); - } - throw error; - } + let captureIndex = 0; - const page = await browser.newPage( { ignoreHTTPSErrors: true } ); + try { + for ( const [ sourcePath, primary ] of captureBySource ) { + const url = resolveCaptureUrl( primary.capture.url ); + let captureError = null; - let captureIndex = 0; + captureIndex += 1; + const relatedEntries = bySource.get( sourcePath ) ?? [ primary ]; + const relatedIds = relatedEntries + .map( ( entry ) => entry.id ) + .join( ', ' ); + const linePrefix = ` [${ captureIndex }/${ captureBySource.size }] Capturing ${ relatedIds }…`; + /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ + const batchResults = []; - try { - for ( const [ sourcePath, primary ] of captureBySource ) { - const url = resolveCaptureUrl( primary.capture.url ); - let captureError = null; - - captureIndex += 1; - const relatedEntries = bySource.get( sourcePath ) ?? [ primary ]; - const relatedIds = relatedEntries - .map( ( entry ) => entry.id ) - .join( ', ' ); - const linePrefix = ` [${ captureIndex }/${ captureBySource.size }] Capturing ${ relatedIds }…`; - /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ - const batchResults = []; + try { + const tempCapturePath = `${ sourcePath }.capture-tmp.png`; + await captureScreenshot( + page, + primary, + manifest.viewports, + tempCapturePath + ); - try { - const tempCapturePath = `${ sourcePath }.capture-tmp.png`; - await captureScreenshot( - page, - primary, - manifest.viewports, - tempCapturePath - ); + const baselinePath = resolveBaselinePath( + relatedEntries, + sourcePath + ); + const entryCompareOptions = resolveCompareOptionsForCapture( + compareOptions, + primary.capture + ); + const compareResult = baselinePath + ? await compareScreenshotFiles( + resolve( rootDir, baselinePath ), + resolve( rootDir, tempCapturePath ), + entryCompareOptions + ) + : { + action: 'update', + diffRatio: 1, + reason: 'no baseline file', + }; + + const tempCaptureAbs = resolve( rootDir, tempCapturePath ); + + if ( compareResult.action === 'skip' ) { + unlinkSync( tempCaptureAbs ); + for ( const entry of relatedEntries ) { + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: 'unchanged', + detail: compareResult.reason, + } ); + } + logCaptureLine( + linePrefix, + captureLineStatus( batchResults ) + ); + results.push( ...batchResults ); + continue; + } - const baselinePath = resolveBaselinePath( - relatedEntries, - sourcePath - ); - const entryCompareOptions = resolveCompareOptionsForCapture( - compareOptions, - primary.capture - ); - const compareResult = baselinePath - ? await compareScreenshotFiles( - resolve( rootDir, baselinePath ), - resolve( rootDir, tempCapturePath ), - entryCompareOptions - ) - : { - action: 'update', - diffRatio: 1, - reason: 'no baseline file', - }; - - const tempCaptureAbs = resolve( rootDir, tempCapturePath ); - - if ( compareResult.action === 'skip' ) { + if ( compareResult.action === 'abort' ) { + unlinkSync( tempCaptureAbs ); + for ( const entry of relatedEntries ) { + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: 'error', + detail: `Screenshot comparison aborted: ${ compareResult.reason }`, + } ); + } + logCaptureLine( + linePrefix, + captureLineStatus( batchResults ) + ); + results.push( ...batchResults ); + continue; + } + + copyAsset( tempCapturePath, sourcePath ); unlinkSync( tempCaptureAbs ); + } catch ( error ) { + captureError = error; + } + + if ( captureError ) { for ( const entry of relatedEntries ) { batchResults.push( { id: entry.id, url, output: entry.embed.path, - status: 'unchanged', - detail: compareResult.reason, + status: 'error', + detail: String( captureError ), } ); } - logCaptureLine( - linePrefix, - captureLineStatus( batchResults ) - ); + logCaptureLine( linePrefix, captureLineStatus( batchResults ) ); results.push( ...batchResults ); continue; } - if ( compareResult.action === 'abort' ) { - unlinkSync( tempCaptureAbs ); - for ( const entry of relatedEntries ) { + for ( const entry of relatedEntries ) { + try { + copyAsset( sourcePath, entry.embed.path ); + verifyDocImageReference( entry ); + entry.embed.status = 'captured'; + manifestDirty = true; + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: relatedEntries.length > 1 ? 'copied' : 'ok', + } ); + } catch ( error ) { batchResults.push( { id: entry.id, url, output: entry.embed.path, status: 'error', - detail: `Screenshot comparison aborted: ${ compareResult.reason }`, + detail: String( error ), } ); } - logCaptureLine( - linePrefix, - captureLineStatus( batchResults ) - ); - results.push( ...batchResults ); - continue; } - copyAsset( tempCapturePath, sourcePath ); - unlinkSync( tempCaptureAbs ); - } catch ( error ) { - captureError = error; - } - - if ( captureError ) { - for ( const entry of relatedEntries ) { - batchResults.push( { - id: entry.id, - url, - output: entry.embed.path, - status: 'error', - detail: String( captureError ), - } ); - } logCaptureLine( linePrefix, captureLineStatus( batchResults ) ); results.push( ...batchResults ); - continue; } - - for ( const entry of relatedEntries ) { - try { - copyAsset( sourcePath, entry.embed.path ); - verifyDocImageReference( entry ); - entry.embed.status = 'captured'; - manifestDirty = true; - batchResults.push( { - id: entry.id, - url, - output: entry.embed.path, - status: relatedEntries.length > 1 ? 'copied' : 'ok', - } ); - } catch ( error ) { - batchResults.push( { - id: entry.id, - url, - output: entry.embed.path, - status: 'error', - detail: String( error ), - } ); - } - } - - logCaptureLine( linePrefix, captureLineStatus( batchResults ) ); - results.push( ...batchResults ); + } finally { + await browser.close(); } - } finally { - await browser.close(); - } - if ( manifestDirty ) { - writeManifest( manifest ); - console.log( ' Syncing screenshots inventory…' ); - syncScreenshotsDocsPage(); - } + if ( manifestDirty ) { + writeManifest( manifest ); + console.log( ' Syncing screenshots inventory…' ); + syncScreenshotsDocsPage(); + } - results.sort( ( a, b ) => a.id.localeCompare( b.id ) ); + results.sort( ( a, b ) => a.id.localeCompare( b.id ) ); - const failures = results.filter( ( row ) => row.status === 'error' ); - const unchanged = results.filter( ( row ) => row.status === 'unchanged' ); - const updated = results.filter( ( row ) => - [ 'ok', 'copied' ].includes( row.status ) - ); + const failures = results.filter( ( row ) => row.status === 'error' ); + const unchanged = results.filter( ( row ) => row.status === 'unchanged' ); + const updated = results.filter( ( row ) => + [ 'ok', 'copied' ].includes( row.status ) + ); - if ( failures.length === 0 ) { - const parts = []; - if ( updated.length > 0 ) { - parts.push( `${ updated.length } updated` ); - } - if ( unchanged.length > 0 ) { - parts.push( `${ unchanged.length } unchanged` ); + if ( failures.length === 0 ) { + const parts = []; + if ( updated.length > 0 ) { + parts.push( `${ updated.length } updated` ); + } + if ( unchanged.length > 0 ) { + parts.push( `${ unchanged.length } unchanged` ); + } + console.log( `Ready.. (${ parts.join( ', ' ) || 'no captures' })` ); + } else { + console.log( + `Ready.. (${ updated.length } updated, ${ unchanged.length } unchanged, ${ failures.length } failed)` + ); } - console.log( `Ready.. (${ parts.join( ', ' ) || 'no captures' })` ); - } else { + + console.log( '\nDoc screenshot summary:\n' ); console.log( - `Ready.. (${ updated.length } updated, ${ unchanged.length } unchanged, ${ failures.length } failed)` + '| id | url | output | status |', + '\n| --- | --- | --- | --- |' ); + for ( const row of results ) { + const detail = row.detail ? ` (${ row.detail })` : ''; + console.log( + `| ${ row.id } | ${ row.url } | ${ row.output } | ${ row.status }${ detail } |` + ); + } + + if ( failures.length > 0 ) { + process.exit( 1 ); + } } - console.log( '\nDoc screenshot summary:\n' ); - console.log( - '| id | url | output | status |', - '\n| --- | --- | --- | --- |' - ); - for ( const row of results ) { - const detail = row.detail ? ` (${ row.detail })` : ''; + if ( ! filterId && allWordpressEntries.length > 0 ) { console.log( - `| ${ row.id } | ${ row.url } | ${ row.output } | ${ row.status }${ detail } |` + `Starting.. (${ allWordpressEntries.length } WordPress.org screenshots)` + ); + await processWordpressEntries( + chromium, + manifest, + allWordpressEntries, + { force } ); - } - - if ( failures.length > 0 ) { - process.exit( 1 ); } }