diff --git a/CHANGELOG.md b/CHANGELOG.md
index 462f7ce9..602be6df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,8 @@
- ``article:section`` - based on the selected category/subcategory ([#858](https://github.com/flatpressblog/flatpress/pull/858))
- ``article:tag`` - based on the tags assigned using the tag plugin ([#858](https://github.com/flatpressblog/flatpress/pull/858))
- ``article:author`` - based on the blog author ([#858](https://github.com/flatpressblog/flatpress/pull/858))
+ - Dynamic Open Graph images ``og:image`` with a social media-friendly aspect ratio of 1.91:1 for posts and static pages. ([#939](https://github.com/flatpressblog/flatpress/pull/939))
+ - ``og:image:alt`` is derived from the optional image captions.
- BBCode plugin: update to version 2.0.4
- The HTML button is only displayed if inline HTML is allowed in BBCode. ([#867](https://github.com/flatpressblog/flatpress/pull/867))
- jQuery plugin update to version 2.2.2 ([#896](https://github.com/flatpressblog/flatpress/pull/896))
Note: Edge Legacy, Internet Explorer 9–10, iOS 7+, and the Android browser on Android 4.0+ are no longer supported. If you need support for these legacy browsers, you must disable the jQuery plugin so that PhotoSwipe uses the older jQuery plugin 3.7.1 version.
@@ -50,6 +52,8 @@
- Re-registering the same email address replaces older, still-open pending tokens.
- Old confirmation links for the same address are thereby invalidated.
- If a local blocklist is missing, a download is attempted before the first form processing.
+- ReadMore plugin update zu version 1.0.4
+ - Revised visibility layer ([#939](https://github.com/flatpressblog/flatpress/pull/939))
### Bugfixes
- PhotoSwipe plugin update to version 2.0.7
diff --git a/fp-plugins/readmore/plugin.readmore.php b/fp-plugins/readmore/plugin.readmore.php
index a9d7c75a..49063ce7 100755
--- a/fp-plugins/readmore/plugin.readmore.php
+++ b/fp-plugins/readmore/plugin.readmore.php
@@ -1,42 +1,118 @@
$string,
+ 'chopped' => false,
+ 'suffix_prefix' => '',
+ 'mode' => $mode
+ );
+
+ // Preserve the historical order: semiauto first behaves like auto and
+ // only reaches the manual [more] branch when the auto condition did not chop.
+ if ($mode === 'auto' || $mode === 'semiauto') {
+ if (strlen($string) > $chopAt) {
+ $result ['content'] = substr($string, 0, $chopAt);
+ $result ['chopped'] = true;
+ $result ['suffix_prefix'] = '… ';
+ return $result;
+ }
+ }
-// WARNING! 'auto' and 'semiauto' modes need improvements! unclosed tags
-// at the chop point will probably result in validation errors!
-// If you're willing to improve it (using a quick but efficient algorithm
-// feel free and then let us know :) )
+ if ($mode === 'manual' || $mode === 'semiauto') {
+ $p = strpos($string, '[more]');
+ if ($p !== false) {
+ $result ['content'] = substr($string, 0, $p);
+ $result ['chopped'] = true;
+ return $result;
+ }
+ } elseif ($mode === 'sentence') {
+ $matches = array();
+ $v = preg_match_all('|[.!?]\s|', $string, $matches, PREG_OFFSET_CAPTURE);
+ if ($v && count($matches [0]) > $chopAt) {
+ $result ['content'] = substr($string, 0, $matches [0] [$chopAt - 1] [1]);
+ $result ['chopped'] = true;
+ $result ['suffix_prefix'] = '. ';
+ }
+ }
+
+ return $result;
+}
-// we recommend using $MODE = 'manual' (SPB legacy behaviour :) )
function plugin_readmore_main($string) {
global $fp_params;
- $MODE = defined('PLUGIN_READMORE_MODE') ? (string) constant('PLUGIN_READMORE_MODE') : 'manual';
- if (!in_array($MODE, array('auto', 'manual', 'semiauto', 'sentence'), true)) {
- $MODE = 'manual';
- }
-
- $CHOP_AT = 4; // characters or sentences
-
$lang = lang_load('plugin:readmore');
$readmoreString = $lang ['plugin'] ['readmore'] ['readmore'];
@@ -46,23 +122,9 @@ function plugin_readmore_main($string) {
if (($q && !$q->single) && !isset($_GET ['page'])) {
list ($id) = $q->getLastEntry();
- if ($MODE == 'auto' || $MODE == 'semiauto') {
- if (strlen($string) > $CHOP_AT) {
- return substr($string, 0, $CHOP_AT) . "… " . $readmoreString . "";
- }
- }
-
- if ($MODE == 'manual' || $MODE == 'semiauto') {
- if (($p = strpos($string, '[more]')) !== false) {
- return substr($string, 0, $p) . "" . $readmoreString . "";
- }
- } elseif ($MODE == 'sentence') {
- $matches = array();
- if ($v = preg_match_all('|[.!?]\s|', $string, $matches, PREG_OFFSET_CAPTURE)) {
- if (count($matches [0]) > $CHOP_AT) {
- $string = substr($string, 0, $matches [0] [$CHOP_AT - 1] [1]) . ". " . $readmoreString . "";
- }
- }
+ $excerpt = plugin_readmore_get_stream_excerpt($string);
+ if (!empty($excerpt ['chopped'])) {
+ return $excerpt ['content'] . $excerpt ['suffix_prefix'] . "" . $readmoreString . "";
}
}
diff --git a/fp-plugins/seometataginfo/developer-docs/README.md b/fp-plugins/seometataginfo/developer-docs/README.md
new file mode 100644
index 00000000..9c5191b1
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/README.md
@@ -0,0 +1,121 @@
+# SEO Meta Tag Info — Developer Documentation
+
+## Scope
+
+Relevant plugin versions:
+
+| Component | Version | Role in this documentation |
+|---|---:|---|
+| SEO Meta Tag Info | 2.3.2 | Primary subject |
+| BBCode | 2.0.4 | Active BBCode grammar and `[img]` rendering |
+| PhotoSwipe | 2.0.7 | `[gallery]`, `[photoswipegallery]`, `[photoswipeimage]`, and `[img]` override |
+| Thumbnails | 1.1.1 | Preview/thumbnail generation behind `bbcode_img_scale` |
+| ReadMore | 1.0.4 | Stream visibility boundary |
+| Gallery captions | 1.0.3 | Author-managed per-gallery image titles persisted through FlatPress gallery helpers |
+| FlatPress | 1.6.dev | Query, gallery, entry, hook, and filesystem APIs |
+
+The current target range used by the project is **PHP 7.2 through PHP 8.5** and **Smarty 4.5.5 through Smarty 5.8.4**. The Open Graph image pipeline itself does not require Smarty-specific syntax.
+
+This is developer documentation, not end-user documentation. The short end-user file `../doc_seometataginfo.txt` remains separate.
+
+## Source-of-truth rule
+
+When this documentation and the code disagree, **the current target code wins**. The most important implementation files are:
+
+- `../plugin.seometataginfo.php`
+- `../inc/og-content-image.php`
+- `../inc/hw-helpers.php`
+- `../inc/class.iniparser.php`
+- `../inc/migrate_data.php`
+- `../tpls/admin.plugin.seometataginfo.tpl`
+- `../regression-test/*.php`
+
+The image pipeline also depends on behavior defined by:
+
+- `../../bbcode/plugin.bbcode.php`
+- `../../photoswipe/plugin.photoswipe.php`
+- `../../photoswipe/photoswipefunctions.class.php`
+- `../../thumb/plugin.thumb.php`
+- `../../readmore/plugin.readmore.php`
+- `../../gallerycaptions/plugin.gallerycaptions.php`
+- `../../gallerycaptions/admin_uploader_gallerycaptions.class.php`
+- `../../../fp-includes/core/core.gallery.php`
+- `../../../fp-includes/core/core.fpdb.class.php`
+
+## Documentation map
+
+1. [Architecture and lifecycle](architecture.md)
+2. [Open Graph image pipeline](open-graph-image-pipeline.md)
+3. [Metadata storage and administration](metadata-storage-admin.md)
+4. [Plugin and core integrations](integrations.md)
+5. [Configuration, caching, and HTTP behavior](configuration-and-caching.md)
+6. [Security model](security.md)
+7. [Compatibility](compatibility.md)
+8. [Testing and static analysis](testing.md)
+9. [API reference](api-reference.md)
+10. [Maintenance and extension guide](maintenance.md)
+
+## High-level responsibilities
+
+SEO Meta Tag Info has several responsibilities that should remain conceptually separate:
+
+- persist per-entry, static-page, category, tag, archive, and default SEO metadata;
+- emit standard SEO meta tags and canonical URLs;
+- emit Open Graph metadata;
+- derive article metadata for single-entry views;
+- select a content-aware Open Graph image;
+- derive `og:image:alt` from the selected image's explicit BBCode `title` or Gallery caption, with site-title fallback;
+- serve locally generated 1200 × 630 Open Graph image responses;
+- expose per-entry SEO description/keywords to Smarty entry templates;
+- provide an administration panel for the host-level `robots.txt`;
+- migrate older SEO metadata layouts when explicitly enabled.
+
+The content-aware image feature is deliberately implemented inside the SEO plugin. BBCode, PhotoSwipe, and Thumb are treated as rendering semantics and dependencies; they do not contain SEO-specific logic.
+
+## Key invariants
+
+Developers changing the image pipeline should preserve these invariants:
+
+1. **Original media selection:** SEO selection is based on the original image source, never on a `.thumbs` preview.
+2. **Source order:** the first valid visible media occurrence wins.
+3. **Gallery order:** `gallery_read_images()` defines gallery ordering, while `gallery_read_captions()` supplies the title for the exact selected gallery file.
+4. **ReadMore visibility:** a multi-entry stream must not publish media that ReadMore hides.
+5. **Single/static completeness:** single entries and static pages may select media after `[more]`.
+6. **No live media rendering during probing:** probing must not create thumbnails or advance PhotoSwipe state.
+7. **Primary query safety:** scanning the stream must not consume or replace the page's active query state.
+8. **Local-path containment:** local OG sources must resolve inside `IMAGES_DIR`.
+9. **No remote fetch:** remote HTTP(S) images are validated syntactically but are not downloaded/proxied by the server.
+10. **No distortion:** transformable local JPEG/PNG sources are fitted proportionally onto the configured OG canvas.
+11. **Explicit invalid source does not become theme preview:** a requested but invalid `seometa_ogsource` remains an invalid content-image request.
+12. **HTML-escaped query tolerance:** copied URLs containing literal `&` / repeated `amp;` parameter prefixes are accepted without weakening parameter precedence or path validation.
+13. **Image-description binding:** an explicit `[img ... title="..."]` or the Gallery caption of the exact selected file becomes `og:image:alt`; missing/empty titles fall back to `general.title` and never change image selection.
+14. **No alt substitution:** a BBCode `alt` attribute, filename, IPTC title, thumbnail title, or caption from a later gallery image must not substitute for a missing user image title.
+
+## Architectural overview
+
+```mermaid
+flowchart LR
+ A[FlatPress request] --> B[SEO Meta Tag Info]
+ B --> C{Dynamic OG image request?}
+ C -- yes --> D[Validate requested local source]
+ D --> E[Render or stream image response]
+ C -- no --> F[wp_head metadata routing]
+ F --> G[Content-aware image resolver]
+ G --> H[BBCode parser clone / media markers]
+ H --> I[ReadMore visibility, when stream]
+ I --> J[Original image or first valid gallery image]
+ J --> JA[Bind explicit image title / exact gallery caption]
+ JA --> K[OG metadata preparation]
+ K --> L[1200 x 630 endpoint for transformable local JPEG/PNG]
+ K --> M[Direct URL fallback for remote/unsupported media]
+ F --> N[Description / keywords / robots / canonical / article metadata]
+```
+
+## What is deliberately not done
+
+- No DOM scraping of the final rendered page.
+- No selection from `
` because it may point to `.thumbs`.
+- No server-side download of remote Open Graph images.
+- No direct mutation of the BBCode, PhotoSwipe, or Thumb plugin for SEO purposes.
+- No use of `get.php` as an image or thumbnail wrapper.
+- No dependency on a specific Apache/nginx/IIS rewrite configuration for the OG endpoint.
diff --git a/fp-plugins/seometataginfo/developer-docs/api-reference.md b/fp-plugins/seometataginfo/developer-docs/api-reference.md
new file mode 100644
index 00000000..9a027e29
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/api-reference.md
@@ -0,0 +1,442 @@
+# API Reference
+
+This reference documents functions/classes in the SEO Meta Tag Info plugin as implemented in the current snapshot. Most functions are plugin-internal; FlatPress does not enforce visibility boundaries for global PHP functions, so names should still be treated as part of the integration surface.
+
+## 1. Main plugin: metadata and flags
+
+### `seometataginfo_flag($name)`
+
+Reads a defined feature-switch constant and returns its boolean value.
+
+### `output_metatags($seo_desc, $seo_keywords, $seo_noindex, $seo_nofollow, $seo_noarchive, $seo_nosnippet)`
+
+Central normal-page metadata emitter. Builds Open Graph image metadata, title, description, keywords, robots, article metadata, canonical URL, and Open Graph URL/type/locale/site name. `og:image:alt` uses the selected image metadata first and the configured site title as fallback.
+
+### `seometataginfo_get_og_image_alt_text($imageMeta, $siteTitle)`
+
+Returns the selected image's normalized `alt` metadata when non-empty, otherwise the configured site title, and finally `Preview` only if both are empty.
+
+### `makePageTitle($title, $sep)`
+
+Adds context-aware localized title information.
+
+### `plugin_seometataginfo_head($file_meta)`
+
+`wp_head` entry point. Resolves metadata source for the current context and calls the output pipeline.
+
+### `plugin_seometataginfo_init()`
+
+`init` entry point. Serves dynamic OG image requests or registers the title filter for normal page requests.
+
+## 2. Query and URL helpers
+
+### `seometataginfo_append_query_args($url, $args)`
+
+Adds RFC3986 query parameters while preserving a URL fragment.
+
+### `seometataginfo_normalize_query_parameter_name($name)`
+
+Removes repeated leading literal `amp;` fragments from a query parameter name.
+
+### `seometataginfo_get_query_parameter($name)`
+
+Returns:
+
+```text
+present
+valid
+value
+```
+
+Exact key has precedence; escaped aliases are fallback names.
+
+### `seometataginfo_url_join($baseUrl, $path)`
+
+Joins a base URL and relative path with one slash.
+
+### `seometataginfo_build_public_url($baseUrl)`
+
+Builds canonical/public URL from configured base URL plus request path/query, then strips tracking parameters.
+
+### `seometataginfo_strip_tracking_params($url)`
+
+Removes the plugin's fixed set of known tracking query keys and rebuilds the URL.
+
+## 3. Runtime configuration and APCu
+
+### `seometataginfo_get_runtime_config()`
+
+Returns `$fp_config`, `EARLY_FP_CONFIG`, or an empty array.
+
+### `seometataginfo_apcu_available()`
+
+Returns true only when FlatPress's APCu helper exists and reports APCu enabled.
+
+### `seometataginfo_normalize_cache_ttl($ttl, $defaultTtl)`
+
+Ensures a positive TTL, with fallback to default and finally 3600 seconds.
+
+### `seometataginfo_get_og_image_binary_apcu_max_bytes()`
+
+Returns the configured binary cache limit, clamped so negative values become zero.
+
+## 4. Generic image metadata/caching
+
+### `seometataginfo_build_image_info(...)`
+
+Builds the shared image-info array containing relative/absolute path, URL, MIME, dimensions, type, mtime, and file size.
+
+### `seometataginfo_get_image_info_apcu_key($absolutePath, $mtime, $sizeBytes)`
+
+Builds APCu key for source metadata.
+
+### `seometataginfo_get_og_image_binary_cache_key($imageInfo, $targetWidth, $targetHeight)`
+
+Builds APCu key for transformed image bytes.
+
+### `seometataginfo_get_cached_og_image_binary(...)`
+
+Reads a cached transformed image body/MIME pair.
+
+### `seometataginfo_store_og_image_binary_cache(...)`
+
+Stores transformed bytes if APCu is active and the configured size limit allows it.
+
+### `seometataginfo_send_image_content_headers($mime, $contentLength)`
+
+Sets MIME and content length, removing a stale content-length header first when possible.
+
+### `seometataginfo_output_binary_image(...)`
+
+Sends cache/content headers and echoes cached/generated image bytes.
+
+### `seometataginfo_capture_image_resource_output($image, $imageInfo)`
+
+Captures JPEG/PNG encoder output from a GD image handle.
+
+### `seometataginfo_get_supported_image_info($baseUrl, $relativePath)`
+
+Validates a local JPEG/PNG theme/fallback source and returns cached metadata.
+
+## 5. OG fallback source
+
+### `seometataginfo_get_theme_preview_image_info($baseUrl)`
+
+Checks style-specific and theme-level preview candidates in this order:
+
+```text
+theme/style/preview.png
+theme/style/preview.jpg
+theme/style/preview.jpeg
+theme/preview.png
+theme/preview.jpg
+theme/preview.jpeg
+```
+
+### `seometataginfo_get_plugin_fallback_image_info($baseUrl)`
+
+Returns metadata for the bundled fallback image.
+
+### `seometataginfo_get_og_image_source_info($baseUrl)`
+
+Returns theme preview if valid, otherwise plugin fallback.
+
+## 6. OG metadata and dynamic endpoint
+
+### `seometataginfo_can_transform_og_image($imageInfo)`
+
+Checks local path/type plus required GD functions for JPEG/PNG transformation.
+
+### `seometataginfo_build_og_image_url($baseUrl, $imageInfo, $contentSource = '')`
+
+Builds public dynamic endpoint URL with `seometa_ogimage`, `v`, and optional validated `seometa_ogsource`.
+
+### `seometataginfo_prepare_og_image_meta($baseUrl, $imageInfo, $contentSource = '')`
+
+Returns public OG metadata. Transformable local sources use the dynamic 1200 × 630 endpoint; otherwise the direct source URL is used. The internal plain-text `alt` value is preserved through either branch.
+
+### `seometataginfo_get_requested_content_og_image_info($baseUrl)`
+
+Rehydrates a content source from the endpoint query and returns both request-presence state and validated metadata.
+
+### `seometataginfo_get_og_image_meta($baseUrl)`
+
+Top-level normal-page OG image chooser: content first, then fallback source.
+
+### `seometataginfo_is_og_image_request()`
+
+Recognizes the endpoint query flag. Accepted scalar values are empty string, `1`, `true`, and `yes`.
+
+### `seometataginfo_send_status($code)`
+
+Sends HTTP status using `http_response_code()` when available; explicit header fallback exists for 304/404.
+
+### `seometataginfo_send_image_cache_headers(...)`
+
+Sends cache-control, ETag, Last-Modified and handles conditional 304 responses.
+
+### `seometataginfo_output_image_file($imageInfo)`
+
+Streams a validated original local file.
+
+### `seometataginfo_image_create_from_file($imageInfo)`
+
+Creates GD source handle for JPEG/PNG.
+
+### `seometataginfo_output_image_resource($image, $imageInfo)`
+
+Encodes GD resource/object according to JPEG/PNG source type.
+
+### `seometataginfo_destroy_image_resource(&$image)`
+
+Cross-version cleanup helper for GD handles.
+
+### `seometataginfo_calculate_og_contain_box(...)`
+
+Pure geometry helper for centered proportional contain-fit.
+
+### `seometataginfo_render_og_image(...)`
+
+Transforms a validated image onto the target canvas, optionally using/storing APCu binary cache.
+
+### `seometataginfo_serve_og_image()`
+
+Dynamic endpoint controller. Explicit content source has priority; invalid explicit source does not become a theme fallback.
+
+## 7. Current entry/article metadata
+
+### `seometataginfo_get_current_single_entry_data()`
+
+Returns cached current entry ID/data without advancing the primary query.
+
+### `seometataginfo_get_category_path($category_id, $separator = '/')`
+
+Builds a parent-to-leaf category path.
+
+### `seometataginfo_get_article_section()`
+
+Returns first valid current-entry category hierarchy.
+
+### `seometataginfo_is_tag_plugin_enabled()`
+
+Checks active/installed Tag plugin state.
+
+### `seometataginfo_get_article_tags()`
+
+Returns unique current-entry tags.
+
+### `seometataginfo_get_article_published_time()`
+
+Returns ISO 8601 entry publication time.
+
+## 8. Metadata-file routing
+
+### `process_meta($file_meta, $type, $id, $sep)`
+
+Shared tag/archive/category metadata-file creation/read/output helper.
+
+### `process_tag_meta()`
+
+Builds tag metadata file/context.
+
+### `process_archive_meta()`
+
+Builds archive metadata file/context.
+
+### `process_category_meta()`
+
+Validates category and builds category metadata file/context.
+
+### `seometa_category_id_exists($cat_id)`
+
+Checks `CONTENT_DIR/categories.txt` for a category ID.
+
+### `seometataginfo_cache_set($id, $desc, $keys)`
+
+Stores entry description/keywords in a request-global cache.
+
+### `seometataginfo_cache_get($id)`
+
+Reads request-global entry metadata cache.
+
+### `seometataginfo_ensure_metafile(&$file_meta)`
+
+Ensures requested metadata file/default metadata exist where possible.
+
+## 9. Administration and Smarty
+
+### `seometataginfo_get_admin_string($key, $default = '')`
+
+Loads an admin translation key with request-local fallback cache.
+
+### `plugin_seometatags_setup()`
+
+Reports robots.txt setup/writability state.
+
+### `seometataginfo_assign_defaults()`
+
+Assigns empty SEO variables to Smarty.
+
+### `seometataginfo_assign_entry_vars($id)`
+
+Loads/memoizes entry SEO metadata and assigns Smarty variables.
+
+### `admin_plugin_seometataginfo`
+
+Admin panel class for robots.txt.
+
+Methods:
+
+- `setup()`
+- `onsubmit($data = null)`
+
+## 10. Entry-editor class
+
+### `plugin_seometatags_entry`
+
+Methods:
+
+- `simple()` — renders SEO fields;
+- `sanitizeSeoField($input)` — sanitizes description/keywords;
+- `do_save()` — writes metadata;
+- `post($data)` — registers publish save callback;
+- `save($id, $arr)` — entry save bridge;
+- `save_static($title)` — static-page save bridge;
+- `__construct()` — registers editor hooks.
+
+## 11. Content image resolver (`inc/og-content-image.php`)
+
+### `seometataginfo_content_empty_image_meta()`
+
+Returns canonical empty image metadata structure.
+
+### `seometataginfo_content_normalize_image_alt($value)`
+
+Normalizes a scalar BBCode image title or Gallery caption for `og:image:alt`: up to two entity-decoding passes, markup removal, CR/LF replacement, and trim. Final HTML attribute escaping remains the output layer's responsibility.
+
+### `seometataginfo_content_unset_global($key)`
+
+Unsets a global key through a helper so static analysis does not incorrectly infer constructor side effects.
+
+### Probe callbacks
+
+- `seometataginfo_content_probe_media_callback(...)`
+- `seometataginfo_content_probe_img(...)`
+- `seometataginfo_content_probe_photoswipeimage(...)`
+- `seometataginfo_content_probe_gallery(...)`
+- `seometataginfo_content_probe_photoswipegallery(...)`
+
+These create ordered markers/tokens rather than rendering media.
+
+### `seometataginfo_content_probe_replace_code($parser, $tag, $callback)`
+
+Replaces one existing cloned parser code while preserving callback params, content type, nesting rules, and flags.
+
+### `seometataginfo_content_probe_media($content)`
+
+Runs the cloned active BBCode parser and returns:
+
+```text
+html
+tokens
+```
+
+### `seometataginfo_content_remote_image_meta($url)`
+
+Validates but does not fetch remote HTTP(S) source.
+
+### `seometataginfo_content_path_is_within($path, $root)`
+
+Canonical path containment predicate.
+
+### `seometataginfo_content_normalize_local_image_path($source)`
+
+Normalizes local image namespace/path and rejects traversal/query/fragment/control characters.
+
+### `seometataginfo_content_local_image_meta($source, $baseUrl)`
+
+Validates local image through realpath, root containment, file checks, and `getimagesize()`.
+
+### `seometataginfo_content_image_meta($source, $baseUrl)`
+
+Dispatches remote versus local image resolution.
+
+### `seometataginfo_content_gallery_meta($source, $baseUrl)`
+
+Validates gallery and selects the first valid original image according to `gallery_read_images()`. After selection, reads `gallery_read_captions()` and binds only the selected filename's normalized caption to the internal `alt` field.
+
+### `seometataginfo_content_resolve_token($token, $baseUrl)`
+
+Dispatches image versus gallery marker. For `[img]`/`[photoswipeimage]`, only the explicit parsed `title` attribute is normalized into the internal `alt` field; BBCode `alt` is not used as the SEO description.
+
+### `seometataginfo_find_first_content_image_meta($content, $baseUrl, $applyReadMore)`
+
+Finds first visible valid original media item in one content string.
+
+### `seometataginfo_get_current_static_content()`
+
+Resolves current static raw content.
+
+### `seometataginfo_get_stream_query_params($query)`
+
+Copies relevant current stream window parameters.
+
+### `seometataginfo_get_stream_content_image_meta($baseUrl)`
+
+Scans a secondary query without consuming the page's primary iterator.
+
+### `seometataginfo_get_content_og_image_meta($baseUrl)`
+
+Top-level context dispatcher for static/single/search/stream content image selection.
+
+## 12. Helper file (`inc/hw-helpers.php`)
+
+Context helpers are conditionally defined when FlatPress has not already supplied them:
+
+- `is_single`
+- `is_comments`
+- `is_static`
+- `is_static_home`
+- `is_blog_home`
+- `is_blog_page`
+- `is_paging`
+- `is_category`
+- `is_tag`
+- `is_feed`
+- `is_search`
+- `is_contact`
+- `is_archive`
+- `is_archive_year`
+- `is_archive_month`
+- `is_archive_day`
+- `get_category_name`
+- `pathinfo_filename`
+- `currentPageURL`
+
+Utility helpers:
+
+- `rrmdir`
+- `rcopy`
+- `is_empty_dir`
+- `echoPre`
+
+## 13. Migration helpers (`inc/migrate_data.php`)
+
+- `create_defaults()`
+- `rmigrate_entries($cur)`
+- `migrate_old()`
+
+These operate on persistent metadata and should be treated as migration tooling rather than normal request-path logic.
+
+## 14. `iniParser` class
+
+Methods:
+
+- `__construct($filename)`
+- `getSection($key)`
+- `getValue($section, $key)`
+- `get($section, $key = null)`
+- `setSection($section, $array)`
+- `setValue($section, $key, $value)`
+- `set($section, $key, $value = null)`
+- `save($filename = null)`
diff --git a/fp-plugins/seometataginfo/developer-docs/architecture.md b/fp-plugins/seometataginfo/developer-docs/architecture.md
new file mode 100644
index 00000000..f38b2426
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/architecture.md
@@ -0,0 +1,165 @@
+# Architecture and Lifecycle
+
+## 1. Plugin load
+
+`plugin.seometataginfo.php` loads four local includes:
+
+```php
+require ('inc/hw-helpers.php');
+require ('inc/og-content-image.php');
+require ('inc/class.iniparser.php');
+require ('inc/migrate_data.php');
+```
+
+The main file then defines feature flags, Open Graph constants, storage-directory constants, admin integration, request hooks, metadata emitters, and image-serving functions.
+
+The include order matters because:
+
+- `hw-helpers.php` supplies context helpers such as `is_single()`, `is_static()`, and `currentPageURL()` only when the corresponding function is not already defined;
+- `og-content-image.php` relies on constants/functions defined by FlatPress at runtime but is safe to load before it is invoked;
+- `iniParser` is used later by metadata routing and admin entry handling;
+- migration helpers depend on the SEO storage constants and are invoked only after those constants are defined.
+
+## 2. FlatPress hooks
+
+Primary hooks registered by the plugin:
+
+| Hook | Priority | Callback | Purpose |
+|---|---:|---|---|
+| `init` | 0 | `seometataginfo_assign_defaults` | Initialize Smarty variables `seo_desc` and `seo_keywords` |
+| `init` | default | `plugin_seometataginfo_init` | Serve dynamic OG requests early; otherwise register title filter |
+| `wp_head` | 1 | `plugin_seometataginfo_head` | Route page metadata and emit SEO/Open Graph tags |
+| `entry_block` | 0 | `seometataginfo_assign_entry_vars` | Populate per-entry SEO Smarty variables |
+| `wp_title` | 10 | `makePageTitle` | Add context-specific page title suffix when enabled |
+
+Admin/editor hooks are documented in [metadata-storage-admin.md](metadata-storage-admin.md).
+
+## 3. Request lifecycle
+
+```mermaid
+sequenceDiagram
+ participant FP as FlatPress bootstrap
+ participant SEO as SEO Meta Tag Info
+ participant INIT as init hook
+ participant HEAD as wp_head
+ participant OUT as HTTP output
+
+ FP->>SEO: load plugin + includes
+ FP->>INIT: run init callbacks
+ INIT->>SEO: seometataginfo_assign_defaults()
+ INIT->>SEO: plugin_seometataginfo_init()
+
+ alt seometa_ogimage request
+ SEO->>SEO: seometataginfo_is_og_image_request()
+ SEO->>SEO: seometataginfo_serve_og_image()
+ SEO-->>OUT: image bytes / 304 / 404
+ else normal page request
+ SEO->>FP: optionally register wp_title filter
+ FP->>HEAD: render head
+ HEAD->>SEO: plugin_seometataginfo_head()
+ SEO->>SEO: route metadata file/context
+ SEO->>SEO: output_metatags()
+ SEO-->>OUT: meta/link elements
+ end
+```
+
+The important separation is that the dynamic image endpoint runs during `init`, while normal metadata generation happens later in `wp_head`.
+
+## 4. Normal metadata routing
+
+`plugin_seometataginfo_head()` distinguishes these contexts:
+
+1. tag;
+2. archive;
+3. category;
+4. single entry;
+5. blog page;
+6. contact page;
+7. ordinary static page;
+8. configured static home page;
+9. fallback/default metadata.
+
+For tag/archive/category contexts, specialized `process_*_meta()` functions call the shared `process_meta()` helper.
+
+For entries and static/blog/contact pages, the function resolves the appropriate INI file, calls `seometataginfo_ensure_metafile()`, reads values through `iniParser`, and calls `output_metatags()`.
+
+## 5. Open Graph image architecture
+
+The normal page request never waits for final entry HTML to exist. Open Graph metadata is needed in `
`, before the template has rendered the full body.
+
+Therefore the image subsystem operates on the current FlatPress data/query model:
+
+```mermaid
+flowchart TD
+ A[output_metatags] --> B[seometataginfo_get_og_image_meta]
+ B --> C[seometataginfo_get_content_og_image_meta]
+ C --> D{Context}
+ D -- static --> E[Static raw content]
+ D -- single --> F[Current entry raw content]
+ D -- stream --> G[Independent FPDB_Query scan]
+ D -- search --> H[No content image]
+ E --> I[Side-effect-free BBCode probe]
+ F --> I
+ G --> I
+ I --> J[First visible valid original media]
+ J --> K{Found?}
+ K -- yes --> L[Prepare content OG metadata]
+ K -- no --> M[Theme preview]
+ M --> N{Available?}
+ N -- no --> O[Bundled fallback image]
+ N -- yes --> P[Prepare fallback metadata]
+ O --> P
+```
+
+
+### Image description flow
+
+The selected image metadata now carries a plain-text `alt` field used only for Open Graph description output:
+
+- `[img]` / `[photoswipeimage]`: the explicit parsed `title` attribute is normalized and attached to the selected source;
+- `[gallery]` / `[photoswipegallery]`: after the first valid gallery file is selected, `gallery_read_captions()` is queried and only that file's caption is attached;
+- missing/empty title/caption: the resolver leaves `alt` empty;
+- `output_metatags()` resolves the final fallback through `seometataginfo_get_og_image_alt_text()`, using `general.title` and finally `Preview` only when the configured site title itself is empty.
+
+This keeps title availability separate from image eligibility: an uncaptioned earlier image still wins over a later captioned image.
+
+## 6. Why the parser is cloned
+
+`seometataginfo_content_probe_media()` calls `plugin_bbcode_init()` and **clones** the active parser. It does not run the real media callbacks.
+
+On the clone, it replaces only media callbacks that are actually registered:
+
+- `img`
+- `photoswipeimage`
+- `gallery`
+- `photoswipegallery`
+
+The replacement callback emits a unique marker and stores the parsed attributes. All other BBCode grammar, nesting rules, content types, and code flags are retained.
+
+This design provides three properties simultaneously:
+
+- syntax matches the current BBCode parser;
+- tags hidden by parser rules such as literal code blocks do not become media;
+- probing does not invoke thumbnail generation or PhotoSwipe rendering state.
+
+## 7. Global-state restoration
+
+Two areas use temporary global state and explicitly restore it.
+
+### Media probe context
+
+`$GLOBALS['seometataginfo_media_probe_context']` stores the current marker counter, nonce, and token list while the cloned parser runs. Any pre-existing value is restored in `finally`.
+
+### Stream query scan
+
+Creating `new FPDB_Query(...)` changes `$GLOBALS['current_query']` as a constructor side effect. Entry parsing may also change `$GLOBALS['post']`.
+
+`seometataginfo_get_stream_content_image_meta()` saves both keys, scans in `try`, and restores/unsets them in `finally`.
+
+This is a critical regression invariant.
+
+## 8. Search context
+
+`seometataginfo_get_content_og_image_meta()` intentionally returns no content image for `is_search()`.
+
+FlatPress search uses a result collection that is different from the ordinary FPDB stream window. Publishing a media item from the ordinary query in that context could therefore be unrelated to the visible search results. The normal theme/plugin fallback remains available.
diff --git a/fp-plugins/seometataginfo/developer-docs/compatibility.md b/fp-plugins/seometataginfo/developer-docs/compatibility.md
new file mode 100644
index 00000000..8cb0e12c
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/compatibility.md
@@ -0,0 +1,157 @@
+# Compatibility
+
+## 1. Target range
+
+Project target:
+
+- PHP **7.2 through 8.5**
+- Smarty **4.5.5 through 5.8.4**
+
+The current snapshot's PHPStan configuration declares:
+
+```yaml
+phpVersion:
+ min: 70200
+ max: 80500
+level: 5
+```
+
+This expresses a static-analysis target range. It is not a substitute for executing runtime tests on every PHP version.
+
+## 2. PHP language constraints
+
+When modifying SEO Meta Tag Info, avoid introducing syntax unavailable on PHP 7.2, such as:
+
+- `match`;
+- union/intersection types;
+- named arguments;
+- attributes;
+- nullsafe operator;
+- arrow functions if PHP 7.2 support must be retained;
+- PHP 8-only string helpers without compatibility wrappers.
+
+The current plugin uses constructs available in the supported baseline, including:
+
+- arrays;
+- closures;
+- `try/finally`;
+- null-coalescing operator;
+- scalar casts;
+- `parse_url`, `getimagesize`, `realpath`;
+- `html_entity_decode`, `strip_tags`, `str_replace`, and scalar checks for image-description normalization;
+- GD functions behind `function_exists()` checks.
+
+## 3. GD object/resource difference
+
+GD image handles changed from resources to objects in newer PHP branches.
+
+The SEO implementation generally accepts both:
+
+```php
+is_object($image) || is_resource($image)
+```
+
+`seometataginfo_destroy_image_resource()` also guards `imagedestroy()` with the FlatPress `is_php85_plus()` compatibility helper when available.
+
+Keep this cross-version distinction in mind for image pipeline changes.
+
+## 4. Smarty compatibility
+
+The OG resolver is independent of Smarty rendering.
+
+Smarty usage is limited to stable APIs:
+
+- `getTemplateVars('static_page')`;
+- `assign(...)`;
+- existing FlatPress form/template helpers in the admin template.
+
+No new Smarty plugin syntax is required by the content-image feature or the `og:image:alt` title/caption feature.
+
+The current `admin.plugin.seometataginfo.tpl` uses ordinary variable interpolation, `{include}`, `{html_form}`, `{if}`, and `escape`.
+
+Gallery-caption lookup happens in PHP through `gallery_read_captions()` and does not introduce any Smarty dependency.
+
+## 5. Webserver portability
+
+The dynamic OG image endpoint is an `index.php` query request:
+
+```text
+index.php?seometa_ogimage=1...
+```
+
+It does not require:
+
+- Apache rewrite rules;
+- nginx-specific location blocks;
+- IIS rewrite rules.
+
+Correct public URL construction still depends on FlatPress's configured public base URL and normal PHP request environment.
+
+## 6. Shared hosting
+
+Design choices that support shared hosting:
+
+- no background worker;
+- no external image fetch;
+- GD optional;
+- APCu optional;
+- no required shell command for runtime;
+- dynamic image served by normal PHP entry point;
+- filesystem checks based on FlatPress constants.
+
+Potential constraints:
+
+- memory required to decode/resample very large source images;
+- write permission required for SEO metadata and robots operations;
+- APCu may be disabled in web or CLI SAPIs independently.
+
+## 7. Image formats
+
+### Content selection
+
+Any local file recognized by `getimagesize()` as `image/*` can be selected as original content metadata.
+
+### Dynamic 1200 × 630 transformation
+
+Currently limited to:
+
+- JPEG;
+- PNG;
+
+and only when the corresponding GD functions exist.
+
+Other local formats use the direct source URL during normal `og:image` metadata preparation.
+
+### Thumb is independent
+
+The Thumb plugin can support a wider preview set depending on GD, including GIF/WebP. This does not automatically expand the SEO dynamic formatter.
+
+## 8. APCu
+
+APCu is a performance enhancement only.
+
+Correct behavior must remain when:
+
+- APCu extension is missing;
+- `is_apcu_on()` returns false;
+- cache get/set fails;
+- rendered image exceeds the configured cache-size threshold.
+
+## 9. Filesystem/path separators
+
+The content image normalizer converts `\` to `/` before namespace and traversal checks.
+
+Canonical filesystem boundaries are then checked through `realpath()`.
+
+This is relevant for Windows/IIS-style paths while retaining public URL slash conventions.
+
+## 10. CLI versus web tests
+
+Regression harnesses define FlatPress/GD stubs when necessary, so a passing CLI test can validate decision logic without proving that the host's web SAPI has GD/APCu enabled.
+
+For releases, combine:
+
+1. PHPStan;
+2. CLI regressions;
+3. at least one web-SAPI smoke test of `og:image`;
+4. runtime checks on the oldest/newest supported PHP versions when those interpreters are available.
diff --git a/fp-plugins/seometataginfo/developer-docs/configuration-and-caching.md b/fp-plugins/seometataginfo/developer-docs/configuration-and-caching.md
new file mode 100644
index 00000000..ef1b1d92
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/configuration-and-caching.md
@@ -0,0 +1,184 @@
+# Configuration, Caching, and HTTP Behavior
+
+## 1. Feature constants
+
+The plugin defines defaults only when a constant is not already defined.
+
+| Constant | Default | Purpose |
+|---|---:|---|
+| `SEOMETA_MIGRATE_DATA` | `false` | Enable legacy SEO metadata migration |
+| `SEOMETA_GEN_OPEN_GRAPH` | `true` | Emit Open Graph tags |
+| `SEOMETA_GEN_TITLE` | `true` | Generate context-aware page titles |
+| `SEOMETA_GEN_TITLE_META` | `true` | Emit `meta name="title"` and `og:title` |
+| `SEOMETA_GEN_IMAGE_META` | `true` | Emit Open Graph image metadata |
+| `SEOMETA_OGIMAGE_TARGET_WIDTH` | `1200` | Dynamic OG canvas width |
+| `SEOMETA_OGIMAGE_TARGET_HEIGHT` | `630` | Dynamic OG canvas height |
+| `SEOMETA_OGIMAGE_QUERY_VAR` | `seometa_ogimage` | Dynamic endpoint selector |
+| `SEOMETA_OGIMAGE_SOURCE_QUERY_VAR` | `seometa_ogsource` | Optional validated local content source |
+| `SEOMETA_OGIMAGE_FALLBACK_RELATIVE_PATH` | `fp-plugins/seometataginfo/imgs/og-image.png` | Bundled fallback |
+| `SEOMETA_OGIMAGE_INFO_APCU_TTL` | at least 60, normally `FP_APCU_IO_TTL`/3600 | Image-info cache TTL |
+| `SEOMETA_OGIMAGE_BINARY_APCU_TTL` | at least 60, normally `FP_APCU_IO_TTL`/3600 | Rendered-image cache TTL |
+| `SEOMETA_OGIMAGE_BINARY_APCU_MAX_BYTES` | `1572864` | Maximum rendered body cached in APCu; `0` means no size limit |
+| `SEOMETA_GEN_CANONICAL` | `true` | Emit canonical link and `og:url` |
+| `SEOMETA_HIDECOMMENTS` | `true` | Canonicalize comment URLs back to page URL |
+
+`seometataginfo_flag()` reads these switch constants as booleans.
+
+## 2. Storage constants
+
+Derived from `CONTENT_DIR`:
+
+```text
+SEOMETA_DIR
+SEOMETA_DEFAULT_DIR
+SEOMETA_ENTRY_DIR
+SEOMETA_STATIC_DIR
+SEOMETA_CATEGORY_DIR
+SEOMETA_TAG_DIR
+SEOMETA_ARCHIVE_DIR
+```
+
+These point to persistent SEO metadata, not cache data.
+
+## 3. Runtime configuration lookup
+
+`seometataginfo_get_runtime_config()` prefers:
+
+1. populated global `$fp_config`;
+2. `$GLOBALS['EARLY_FP_CONFIG']`;
+3. empty array.
+
+This is important for early dynamic image requests, which may run before every normal page subsystem is fully initialized.
+
+## 4. Image metadata cache
+
+`seometataginfo_get_supported_image_info()` uses two cache layers:
+
+### Request-local static cache
+
+Identity includes:
+
+```text
+absolute path | mtime | size | base URL
+```
+
+### APCu
+
+APCu key:
+
+```text
+seometa:og:imageinfo:v1:
+```
+
+The cached record contains:
+
+- MIME;
+- width;
+- height;
+- image type.
+
+The key includes file mtime and size, so a changed source naturally creates a different cache identity.
+
+## 5. Rendered binary cache
+
+Dynamic JPEG/PNG output can be captured and stored in APCu.
+
+Key form:
+
+```text
+seometa:og:imagebin:v1:
+```
+
+This means changing:
+
+- source file;
+- source type;
+- source mtime;
+- source size;
+- target dimensions
+
+changes the binary cache key.
+
+The default maximum cached binary size is 1.5 MiB.
+
+If APCu is unavailable, rendering still works; output is simply generated directly.
+
+## 6. `iniParser` caching
+
+`iniParser` uses:
+
+- a request-local static cache;
+- optional APCu hot cache.
+
+When APCu is active, mtime and file size contribute to the INI cache token. The APCu TTL used there is 600 seconds.
+
+This cache is separate from the OG image caches.
+
+Image-description metadata is intentionally **not** part of the transformed-image binary cache key or dynamic endpoint query. Two pages can therefore reference the same source image with different `og:image:alt` text without generating duplicate image bytes. The description travels only in normal page metadata.
+
+## 7. Browser/crawler HTTP caching
+
+`seometataginfo_send_image_cache_headers()` emits:
+
+```text
+Cache-Control: public, max-age=86400
+ETag: W/"..."
+Last-Modified: ...
+```
+
+The ETag source includes:
+
+- mtime;
+- target width/height;
+- absolute path.
+
+Conditional requests are supported through:
+
+- `If-None-Match`;
+- `If-Modified-Since`.
+
+Matching conditions return HTTP 304 and exit.
+
+## 8. Cache-busting query version
+
+The public dynamic image URL contains:
+
+```text
+v=
+```
+
+The endpoint does not use `v` as a trust boundary. Source validation is based on `seometa_ogsource` and filesystem checks.
+
+`v` exists primarily to make the public URL change when the source's modification time changes.
+
+## 9. MIME and output format
+
+Theme preview candidates are accepted only through `seometataginfo_get_supported_image_info()`, which currently validates JPEG/PNG image types.
+
+Content-local metadata can identify other `image/*` types, but `seometataginfo_can_transform_og_image()` transforms only JPEG and PNG.
+
+For transformable types:
+
+- JPEG source → JPEG output at quality 90;
+- PNG source → PNG output.
+
+The target canvas is a true-color image with a white background.
+
+## 10. Optional GD
+
+If the required GD functions do not exist:
+
+- content selection still works;
+- remote images remain direct URLs;
+- unsupported/untransformable local content metadata uses the direct original URL in normal OG metadata generation;
+- the dynamic endpoint can stream a validated original file if transformation is unavailable.
+
+Do not make GD a hard dependency unless FlatPress's platform requirements explicitly change.
+
+## 11. Optional APCu
+
+`seometataginfo_apcu_available()` requires the FlatPress `is_apcu_on()` helper and an enabled APCu state.
+
+No feature depends on APCu for correctness.
+
+APCu only reduces repeated metadata/image processing.
diff --git a/fp-plugins/seometataginfo/developer-docs/integrations.md b/fp-plugins/seometataginfo/developer-docs/integrations.md
new file mode 100644
index 00000000..b6eade4f
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/integrations.md
@@ -0,0 +1,229 @@
+# Plugin and Core Integrations
+
+## 1. Integration map
+
+```mermaid
+flowchart LR
+ SEO[SEO Meta Tag Info] --> BB[BBCode 2.0.4]
+ SEO --> RM[ReadMore 1.0.4]
+ SEO --> PS[PhotoSwipe 2.0.7]
+ SEO -. understands preview behavior .-> TH[Thumbnails 1.1.1]
+ SEO --> GAL[core.gallery.php]
+ SEO --> GC[Gallery captions 1.0.3]
+ GC --> GAL
+ SEO --> Q[FPDB_Query]
+ SEO --> ENTRY[entry_parse / static_parse]
+ BB --> TH
+ PS --> BB
+ PS --> GAL
+ RM --> Q
+```
+
+The arrows do not all represent direct function calls. Some represent semantic dependencies that the SEO resolver deliberately mirrors without invoking the renderer.
+
+## 2. BBCode
+
+### Relevant hooks
+
+BBCode registers:
+
+```text
+the_content priority 1 -> BBCode
+the_excerpt priority 1 -> BBCode
+bbcode_init -> extension point
+```
+
+`plugin_bbcode_init()` constructs the parser and applies the `bbcode_init` filter before returning it.
+
+SEO calls `plugin_bbcode_init()` to obtain the **active grammar**, then clones the parser.
+
+### `[img]`
+
+BBCode's image callback starts with the original source:
+
+```php
+$absolutepath = $actualpath = $attributes['default'];
+```
+
+For local `images/...` sources, `bbcode_remap_url()` maps the namespace to `IMAGES_DIR`.
+
+When rendered dimensions differ, BBCode calls `bbcode_img_scale`. The resulting thumbnail path can become `
`.
+
+This is the core reason SEO must not select the final HTML `
`.
+
+### Attachments versus images
+
+Current BBCode behavior explicitly keeps image paths as direct filesystem/public paths. The `get.php` wrapper is for attachment downloads, not for image or thumbnail distribution.
+
+The SEO image resolver therefore uses direct image paths under `IMAGES_DIR`.
+
+## 3. PhotoSwipe
+
+PhotoSwipe requires BBCode.
+
+At `init`, `PhotoSwipeFunctions::initializePluginTags()`:
+
+- adds `[gallery]`;
+- adds legacy `[photoswipegallery]`;
+- removes/replaces `[img]`;
+- adds legacy `[photoswipeimage]`.
+
+### Image rendering
+
+PhotoSwipe's `getImageHtml()`:
+
+- obtains the original image URL;
+- delegates preview-image HTML to `do_bbcode_img()`;
+- can therefore display a thumbnail as the preview;
+- puts the original in PhotoSwipe's `href`/`contentUrl` path;
+- increments a class-wide `lastusedDataIndex` when PhotoSwipe markup is produced.
+
+SEO's cloned-parser marker callbacks avoid calling `getImageHtml()`, so probing does not advance this index.
+
+### Gallery rendering
+
+PhotoSwipe's `getGalleryHtml()` calls:
+
+```php
+gallery_read_images($dir)
+gallery_read_captions($dir)
+```
+
+and then renders each file through `getImageHtml()`.
+
+SEO does not render the gallery. It calls `gallery_read_images()` for source order and, after the exact valid file has been selected, `gallery_read_captions()` for that file's Open Graph image description.
+
+### Deactivated PhotoSwipe
+
+Because SEO only replaces media codes that are registered in the active parser:
+
+- normal BBCode `[img]` still works;
+- PhotoSwipe-specific aliases are not treated as valid media unless registered;
+- `[gallery]` is not invented by SEO when the active parser does not define it.
+
+This prevents SEO metadata from advertising media the page renderer itself would not recognize.
+
+## 4. Thumbnails
+
+Thumb registers:
+
+```text
+bbcode_img_scale priority 0 -> plugin_thumb_bbcodehook
+```
+
+It stores generated thumbnails in:
+
+```text
+/.thumbs/
+```
+
+Thumb currently supports creation paths for GIF, JPEG, PNG, and WebP when the corresponding GD functions exist.
+
+The SEO resolver does not call the thumbnail filter. Its only contract with Thumb is an invariant:
+
+> `.thumbs` is display-preview infrastructure and must never become the selected Open Graph content source.
+
+The OG formatter has its own transient HTTP rendering pipeline and does not write the 1200 × 630 result into `.thumbs`.
+
+## 5. ReadMore
+
+ReadMore registers:
+
+```text
+the_content priority 1 -> plugin_readmore_main
+```
+
+The patched 1.0.4 version exposes:
+
+```php
+plugin_readmore_get_mode()
+plugin_readmore_get_stream_excerpt()
+```
+
+The latter contains the same chopping algorithm used by `plugin_readmore_main()` but does not build links or inspect the current query.
+
+SEO uses this helper only for stream visibility.
+
+### Why the helper receives probe HTML
+
+ReadMore normally sees content after the BBCode filter stage in the standard plugin ordering.
+
+The SEO probe therefore parses the content with the real BBCode grammar (media callbacks replaced by markers) and gives the resulting text to `plugin_readmore_get_stream_excerpt()`.
+
+This avoids maintaining a second, slightly different ReadMore algorithm inside SEO.
+
+## 6. FlatPress gallery core
+
+`gallery_read_images()` is the canonical source for gallery file order.
+
+It:
+
+1. remaps the `images/` prefix to `IMAGES_DIR`;
+2. obtains the directory list through `fs_filelister`;
+3. excludes caption metadata files;
+4. sorts filenames.
+
+SEO reuses this function rather than implementing its own directory scan.
+
+### Gallery captions
+
+The Gallery captions plugin is primarily the authoring/admin writer for per-image captions. It sanitizes submitted values and persists them through `gallery_write_captions()`.
+
+SEO does **not** depend on the Gallery captions admin class or on a frontend plugin callback. It reads the canonical persisted data through the FlatPress core function `gallery_read_captions()`, which is the same reader PhotoSwipe uses.
+
+For a selected gallery file ``:
+
+```text
+gallery_read_images() -> choose first valid
+gallery_read_captions() -> caption[] only
+```
+
+If that key is absent or empty, the image remains selected and `og:image:alt` falls back to the configured site title.
+
+This separation means Gallery captions can remain an optional authoring feature while SEO consumes the core gallery data format directly.
+
+## 7. FPDB query
+
+`FPDB_Query::__construct()` sets:
+
+```php
+$GLOBALS['current_query'] = &$this;
+```
+
+`hasMore()` and `peekEntry()` may prepare the query. `getEntry()` advances the walker/pointer.
+
+SEO therefore follows two different rules:
+
+### Current single entry
+
+Use `peekEntry()` so the primary query is not advanced.
+
+### Multi-entry stream scan
+
+Create a separate lightweight query and advance **that** query. Always restore `$GLOBALS['current_query']` and `$GLOBALS['post']`.
+
+## 8. Tag plugin
+
+SEO uses the Tag plugin only for single-entry `article:tag` metadata.
+
+The tag integration is optional and guarded by plugin availability/enabled checks.
+
+When the fallback content parser (`tag_list()`) is needed, the Tag plugin's previous internal tag list is restored after inspection.
+
+## 9. Smarty
+
+The Open Graph image resolver is PHP-side and does not depend on Smarty syntax.
+
+Smarty integration is limited primarily to:
+
+- reading the current `static_page` template variable when available;
+- assigning `seo_desc` / `seo_keywords`;
+- rendering the robots administration template.
+
+This keeps the media-selection architecture stable across Smarty 4.5.5 and 5.8.4.
+
+## 10. Hook-order design note
+
+The SEO `` callback runs at `wp_head` priority 1. By normal FlatPress lifecycle, `init` has already run, allowing optional plugins such as PhotoSwipe to register their BBCode tags before SEO probes the active parser.
+
+Avoid moving content-image selection to an earlier lifecycle stage unless the active parser registration order is re-verified.
diff --git a/fp-plugins/seometataginfo/developer-docs/maintenance.md b/fp-plugins/seometataginfo/developer-docs/maintenance.md
new file mode 100644
index 00000000..57574213
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/maintenance.md
@@ -0,0 +1,242 @@
+# Maintenance and Extension Guide
+
+## 1. Preserve the separation of concerns
+
+When adding features, keep these layers distinct:
+
+1. **context selection** — which content is relevant to this request;
+2. **media probing** — where media occurs in parser/source order;
+3. **visibility** — whether ReadMore exposes it in a stream;
+4. **source resolution** — original local/remote/gallery media;
+5. **image description** — explicit image `title` or exact selected gallery caption;
+6. **public OG metadata** — dynamic endpoint versus direct URL plus final site-title fallback;
+7. **dynamic response** — validation, caching, transform, output.
+
+Do not collapse these stages into one HTML-scraping function.
+
+## 2. Adding a new BBCode media tag
+
+If another plugin introduces a new media tag that should participate in `og:image` selection:
+
+1. verify when/how that tag is registered in the active BBCode parser;
+2. add a marker callback;
+3. add the tag/callback pair in `seometataginfo_content_probe_media()`;
+4. extend `seometataginfo_content_resolve_token()` with its original-source semantics;
+5. keep the cloned parser's original content type, nesting rules, callback params, and flags;
+6. test behavior when the providing plugin is enabled and disabled;
+7. test placement inside `[code]` or other restricted parser contexts;
+8. verify probing does not invoke the media plugin's rendering side effects.
+
+Do not recognize a tag solely by regex when the active parser does not register it.
+
+## 3. Adding a transformable local image format
+
+Transformation support currently requires coordinated changes.
+
+Review at least:
+
+- source metadata/type validation;
+- `seometataginfo_can_transform_og_image()`;
+- `seometataginfo_image_create_from_file()`;
+- `seometataginfo_capture_image_resource_output()`;
+- `seometataginfo_output_image_resource()`;
+- MIME output;
+- APCu binary cache;
+- regression harness stubs;
+- real GD runtime support across PHP target versions.
+
+Do not infer support from Thumb. Thumb and SEO have separate transformation pipelines.
+
+## 4. Changing target dimensions
+
+The target is configured by:
+
+```text
+SEOMETA_OGIMAGE_TARGET_WIDTH
+SEOMETA_OGIMAGE_TARGET_HEIGHT
+```
+
+The binary cache key already contains target dimensions.
+
+If defaults change:
+
+- update social metadata expectations;
+- update geometry regression cases;
+- verify `og:image:width`/`height`;
+- verify cache headers/ETag behavior;
+- verify image memory usage;
+- update this documentation.
+
+The contain-fit algorithm is dimension-agnostic and should remain proportional.
+
+## 5. Changing background policy
+
+Current transformed images are centered on **white**.
+
+If introducing transparent, blurred, colored, or crop-based backgrounds, treat that as a behavioral change. Verify:
+
+- JPEG cannot preserve transparency;
+- PNG output semantics;
+- social-media preview appearance;
+- memory/caching impact;
+- whether "no distortion" remains true;
+- whether cropping is acceptable.
+
+## 6. Changing ReadMore
+
+SEO intentionally reuses `plugin_readmore_get_stream_excerpt()`.
+
+If ReadMore's chopping behavior changes:
+
+1. keep the helper and normal renderer aligned;
+2. rerun `compare_readmore_behavior.php` against the intended reference;
+3. rerun content-image visibility tests;
+4. test media immediately before/after boundaries;
+5. test all modes;
+6. verify `$_GET['page']` behavior.
+
+Never copy a new ReadMore algorithm into SEO unless there is no reusable contract.
+
+## 7. Changing BBCode or PhotoSwipe
+
+Any change to parser initialization, callback flags, nesting rules, or PhotoSwipe tag registration can affect the marker probe.
+
+After such changes run:
+
+```bash
+php fp-plugins/seometataginfo/regression-test/validate_target_parser.php
+php fp-plugins/seometataginfo/regression-test/simulate_og_content_image.php
+```
+
+Also verify that PhotoSwipe's internal `lastusedDataIndex` is not changed by an SEO head request.
+
+### Image-title contract
+
+If BBCode, PhotoSwipe, or Gallery captions changes how titles are parsed or persisted, preserve these SEO rules:
+
+1. source selection happens before title fallback;
+2. `[img]` uses only the explicit parsed `title` attribute;
+3. Gallery uses the caption keyed by the exact selected valid filename;
+4. missing title/caption never advances to a later image;
+5. `general.title` fallback is applied only at metadata output;
+6. the description does not participate in transformed-image cache identity.
+
+After such changes rerun both parser validation and content-image simulation.
+
+## 8. Changing Thumb
+
+SEO should continue to ignore `.thumbs`.
+
+If Thumb changes its directory name or rendering contract, the SEO resolver usually should **not** need to change, because it selects from original tag attributes.
+
+A requirement to modify SEO because of a thumbnail-path change is a warning sign that selection may have become coupled to rendered HTML.
+
+## 9. Changing query handling
+
+Be careful with `FPDB_Query`.
+
+Important target-code facts:
+
+- constructor sets global `current_query`;
+- `getEntry()` advances the pointer/walker;
+- `peekEntry()` does not advance the entry window;
+- query preparation can happen lazily through `hasMore()`/`peekEntry()`.
+
+Any stream scanner must preserve the primary template query.
+
+## 10. Changing dynamic endpoint query names
+
+If `SEOMETA_OGIMAGE_QUERY_VAR` or `SEOMETA_OGIMAGE_SOURCE_QUERY_VAR` changes:
+
+- `seometataginfo_build_og_image_url()` and request parsing use the constants already;
+- update manual smoke-test URLs;
+- verify literal `&` alias handling;
+- keep exact-key precedence;
+- retain array-value rejection.
+
+## 11. Query-entity robustness
+
+Do not "fix" the HTML output by emitting unescaped `&` inside attributes. HTML escaping is correct.
+
+The robustness logic belongs in query-parameter normalization for literal copied-source requests.
+
+Regression cases should include:
+
+```text
+&seometa_ogsource=...
+&seometa_ogsource=...
+&seometa_ogsource=...
+```
+
+plus an exact-key/alias conflict.
+
+## 12. Changing local-path policy
+
+Any relaxation should be security-reviewed.
+
+Current invariant:
+
+> a local content source must normalize into the `IMAGES_DIR` namespace and its canonical real path must remain inside the canonical image root.
+
+Do not replace the canonical containment check with a simple substring test on the untrusted raw source.
+
+## 13. Remote-image feature requests
+
+The current design intentionally does not fetch remote images.
+
+If a future feature proposes resizing remote images, it introduces a new security/operations domain:
+
+- SSRF;
+- DNS rebinding;
+- private/link-local IP filtering;
+- redirects;
+- timeouts;
+- maximum body size;
+- MIME validation;
+- decompression bombs;
+- TLS handling;
+- cache policy;
+- proxy/privacy expectations.
+
+That should be designed as a separate reviewed feature, not a small extension of `seometataginfo_content_remote_image_meta()`.
+
+## 14. Generated test results
+
+The current snapshot demonstrates why generated JSON must not be assumed current: the format-validation script contains more assertions than its stored result JSON.
+
+Recommended policy after every regression change:
+
+```text
+change test script
+→ run script
+→ capture JSON
+→ verify total/passed/failed
+→ update result artifact if the project tracks it
+```
+
+A CI job should preferably run the scripts rather than trusting pre-generated result files.
+
+## 15. Version bump discipline
+
+Behavioral plugin changes should update the `Version:` header in `plugin.seometataginfo.php`.
+
+If a shared helper contract in ReadMore changes, update the ReadMore version as well.
+
+Avoid unrelated version bumps in BBCode/PhotoSwipe/Thumb when those files are not changed.
+
+## 16. Documentation update triggers
+
+Update these developer docs when any of the following changes:
+
+- plugin version;
+- OG source priority;
+- supported media tags;
+- ReadMore integration;
+- target dimensions/background policy;
+- supported transformed formats;
+- endpoint query variables;
+- path-security rules;
+- APCu/cache headers;
+- metadata storage schema;
+- test matrix or commands;
+- dependency plugin versions or relevant hook behavior.
diff --git a/fp-plugins/seometataginfo/developer-docs/metadata-storage-admin.md b/fp-plugins/seometataginfo/developer-docs/metadata-storage-admin.md
new file mode 100644
index 00000000..c670918e
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/metadata-storage-admin.md
@@ -0,0 +1,306 @@
+# Metadata Storage and Administration
+
+## 1. Storage layout
+
+SEO metadata is stored under:
+
+```text
+CONTENT_DIR . seometa/
+```
+
+Constants:
+
+```text
+SEOMETA_DIR
+├── default/ -> SEOMETA_DEFAULT_DIR
+├── entries/ -> SEOMETA_ENTRY_DIR
+├── statics/ -> SEOMETA_STATIC_DIR
+├── categories/ -> SEOMETA_CATEGORY_DIR
+├── tags/ -> SEOMETA_TAG_DIR
+└── archives/ -> SEOMETA_ARCHIVE_DIR
+```
+
+Typical file names:
+
+| Context | File |
+|---|---|
+| default | `default/metatags.ini` |
+| entry | `entries/_metatags.ini` |
+| static page | `statics/_metatags.ini` |
+| blog page | `statics/blog_metatags.ini` |
+| contact | `statics/contact_metatags.ini` |
+| category | `categories/cat-_metatags.ini` |
+| tag | `tags/tag-_metatags.ini` |
+| archive | `archives/archive-20YY[-MM][-DD]_metatags.ini` |
+
+These files are persistent instance data and should not be treated like regenerable Smarty compile/cache files.
+
+## 2. INI schema
+
+The plugin writes a `[meta]` section containing:
+
+```ini
+[meta]
+description=
+keywords=
+noindex=0
+nofollow=0
+noarchive=0
+nosnippet=0
+```
+
+`seometataginfo_ensure_metafile()` creates missing page-specific files by copying the default metadata content when possible. If even the default file cannot be produced, the head callback still emits basic metadata from in-memory defaults.
+
+## 3. `iniParser`
+
+`inc/class.iniparser.php` is a local INI reader/writer with request and optional APCu caching.
+
+### Read path
+
+- resolves the filename with `realpath()` when possible;
+- checks existence;
+- when APCu is active, includes mtime and file size in cache identity;
+- uses a request-local static cache;
+- optionally uses APCu;
+- opens the file in binary mode;
+- uses a shared lock where possible;
+- falls back to `io_load_file()` if direct locked reading is unavailable;
+- tolerates a UTF-8 BOM on the first line;
+- parses section headers and `key=value` lines.
+
+### Write path
+
+`save()`:
+
+- writes to a unique temporary file;
+- requires a writable destination directory;
+- uses a side lock file when possible;
+- exclusively locks the temporary file;
+- flushes and closes it;
+- renames the temporary file into place;
+- applies `FILE_PERMISSIONS`;
+- clears stat cache.
+
+Most current SEO save paths use `io_write_file()` directly; `iniParser::save()` remains part of the helper API.
+
+## 4. Entry/static editor integration
+
+When the admin-panel compatibility condition is met, `plugin_seometatags_entry` is instantiated.
+
+Hooks:
+
+| Hook | Method |
+|---|---|
+| `simple_metatag_info` | `simple()` |
+| `admin_entry_write_onsave` | `post()` |
+| `admin_entry_write_onsavecontinue` | `post()` |
+| `publish_post` | `save()` registered by `post()` |
+| `title_save_pre` | `save_static()` |
+
+`simple()` renders:
+
+- description text field;
+- keyword text field;
+- `noindex`;
+- `nofollow`;
+- `noarchive`;
+- `nosnippet`;
+- hidden metadata-file path.
+
+## 5. Field sanitization
+
+`sanitizeSeoField()`:
+
+1. requires a string;
+2. HTML-decodes the input;
+3. removes HTML-like tags;
+4. removes encoded angle brackets;
+5. removes inline `on...="..."` / `on...='...'` patterns;
+6. filters remaining characters through the plugin's Unicode/extra-character allow pattern;
+7. trims the result.
+
+The character regex is built from Unicode categories for letters, numbers, punctuation, spaces, and marks plus an explicit extra-character list.
+
+Checkbox values default to `"0"` when absent.
+
+## 6. Save routing
+
+`do_save()` chooses the destination as follows:
+
+- existing non-default metadata path → overwrite that metadata file;
+- new entry (`$_REQUEST['p'] === 'entry'`) → derive entry ID from timestamp;
+- new static page (`$_REQUEST['p'] === 'static'`) → use request page ID.
+
+The plugin writes the `[meta]` section using `io_write_file()`.
+
+## 7. Head metadata output
+
+`output_metatags()` can emit:
+
+### Standard meta
+
+- `meta name="title"` when enabled;
+- `meta name="description"`;
+- `meta name="keywords"`;
+- `meta name="robots"` when at least one restriction is active;
+- `meta name="author"` on single entries.
+
+### Open Graph
+
+When Open Graph is enabled:
+
+- `og:title`
+- `og:image`
+- `og:image:url`
+- optional `og:image:secure_url`
+- optional `og:image:type`
+- `og:image:alt`
+- optional `og:image:width`
+- optional `og:image:height`
+- `og:description`
+- `og:type`
+- `og:locale`
+- `og:site_name`
+- `og:url`
+
+`og:image:alt` is not stored in the SEO INI schema. It is derived per request from the selected content image:
+
+- explicit BBCode `title` for `[img]` / `[photoswipeimage]`;
+- persisted Gallery caption for the exact selected gallery file;
+- otherwise `$fp_config['general']['title']`.
+
+Gallery captions remain gallery data managed through `gallery_write_captions()` / `gallery_read_captions()`, not SEO metadata under `CONTENT_DIR . seometa/`.
+
+Single entries use `og:type=article`; other contexts use `og:type=website`.
+
+### Article metadata for single entries
+
+When available:
+
+- `article:author`
+- `article:published_time`
+- `article:section`
+- repeated `article:tag`
+
+## 8. Article section
+
+`seometataginfo_get_article_section()` uses the first valid non-zero category assigned to the current entry.
+
+`seometataginfo_get_category_path()` walks category parents and emits a readable hierarchy joined by `/`.
+
+Loop protection is implemented with a `seen` set. Invalid/non-numeric category IDs are ignored.
+
+## 9. Article tags
+
+`seometataginfo_get_article_tags()` is active only when:
+
+- current context is a single entry;
+- `tag` is listed in `$fp_plugins`;
+- `plugin_exists('tag')` does not report the plugin missing.
+
+It prefers the tag plugin's `entryTags($entry_id)` API. If needed, it can use the entry content and `tag_list()` while preserving/restoring the tag plugin's previous internal tag state.
+
+Returned tags are trimmed, empty entries removed, and duplicates collapsed.
+
+## 10. Published time
+
+`seometataginfo_get_article_published_time()`:
+
+1. prefers the entry's numeric `date` through `date_iso8601()`;
+2. falls back to converting the entry ID with `date_id_to_iso8601()`.
+
+## 11. Canonical URL
+
+`seometataginfo_build_public_url()` prefers the configured base URL (`general.www`) over a URL assembled solely from the request host.
+
+It combines that base with the current request path/query and then calls `seometataginfo_strip_tracking_params()`.
+
+Stripped tracking keys currently include:
+
+- `fbclid`
+- `gclid`
+- `yclid`
+- `mc_cid`
+- `mc_eid`
+- `igshid`
+- `_hsenc`
+- `_hsmi`
+- `utm_source`
+- `utm_medium`
+- `utm_campaign`
+- `utm_term`
+- `utm_content`
+
+When `SEOMETA_HIDECOMMENTS` is enabled, comment-path/comment-fragment portions are additionally removed by `output_metatags()` before canonical/`og:url` output.
+
+## 12. Context titles
+
+`makePageTitle()` adds localized labels for:
+
+- contact;
+- static home;
+- blog home;
+- blog page;
+- tag;
+- archive year/month/day;
+- category;
+- search;
+- paginated page number.
+
+It is registered on `wp_title` only when `SEOMETA_GEN_TITLE` is enabled.
+
+## 13. Smarty variables
+
+At `init` priority 0:
+
+```text
+seo_desc = ""
+seo_keywords = ""
+```
+
+At `entry_block` priority 0, `seometataginfo_assign_entry_vars($id)` resolves per-entry metadata and assigns those two variables.
+
+A request-local memo and the plugin's global entry cache reduce duplicate INI reads.
+
+This interface is intentionally simple and works with the template-level `assign()` API used by both supported Smarty branches.
+
+## 14. robots.txt administration
+
+The admin panel is registered as:
+
+```text
+plugin / seometataginfo
+```
+
+The template is:
+
+```text
+tpls/admin.plugin.seometataginfo.tpl
+```
+
+The implementation uses:
+
+```text
+$_SERVER['DOCUMENT_ROOT'] . '/robots.txt'
+```
+
+Important operational consequences:
+
+- `DOCUMENT_ROOT` must be available;
+- the document root must be writable to create the file;
+- an existing `robots.txt` must be writable to edit it;
+- this path is host-root based, not FlatPress-subdirectory based.
+
+The default generated content disallows FlatPress admin/login/setup and selected internal paths, and adds a sitemap line when `.htaccess` exists.
+
+## 15. Migration
+
+Migration is disabled by default:
+
+```php
+SEOMETA_MIGRATE_DATA = false
+```
+
+When explicitly enabled, `migrate_old()` moves/copies older metadata layouts into the current SEO directories and creates default/blog/contact metadata where missing.
+
+Because migration changes persistent instance data, it should not be enabled casually or left enabled indefinitely.
diff --git a/fp-plugins/seometataginfo/developer-docs/open-graph-image-pipeline.md b/fp-plugins/seometataginfo/developer-docs/open-graph-image-pipeline.md
new file mode 100644
index 00000000..37487f5f
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/open-graph-image-pipeline.md
@@ -0,0 +1,398 @@
+# Open Graph Image Pipeline
+
+## 1. Selection goal
+
+The content-aware Open Graph implementation selects the first valid **original** image that corresponds to what the current page shows.
+
+Supported media tags are determined from the active BBCode grammar:
+
+- `[img=...]`
+- `[photoswipeimage=...]`
+- `[gallery=...]`
+- `[photoswipegallery=...]`
+
+A tag is only probed when it is actually registered in the active parser. This is important for optional PhotoSwipe integration.
+
+## 2. Source priority
+
+The final `og:image` source priority is:
+
+1. first valid content image for the current page context;
+2. active style/theme preview;
+3. bundled `fp-plugins/seometataginfo/imgs/og-image.png`.
+
+```mermaid
+flowchart TD
+ A[Resolve OG image] --> B{Valid content image?}
+ B -- yes --> C[Use content image metadata]
+ B -- no --> D{Valid active style/theme preview?}
+ D -- yes --> E[Use preview metadata]
+ D -- no --> F[Use bundled plugin fallback]
+ C --> G[Prepare public OG metadata]
+ E --> G
+ F --> G
+```
+
+## 3. Page-context rules
+
+### Single entry
+
+`seometataginfo_get_current_single_entry_data()` prefers `FPDB_Query::peekEntry()`, then falls back to `$fp_params['entry']`, and finally `entry_parse()` when required.
+
+The entire entry content is eligible. Media after `[more]` can therefore be selected.
+
+### Static page
+
+`seometataginfo_get_current_static_content()` first tries Smarty's `static_page` template variable. It falls back to the page ID from `$fp_params['page']` or the configured start page and then calls `static_parse()`.
+
+The entire static-page content is eligible. `[more]` does not impose a visibility boundary here.
+
+### Multi-entry stream
+
+The active page query is not consumed.
+
+`seometataginfo_get_stream_query_params()` copies the visible query window into a lightweight secondary query using:
+
+- `fullparse = false`
+- `start`
+- `count`
+- optional `y`, `m`, `d`
+- optional `category`
+- optional `exclude`
+
+The secondary query is iterated in stream order. Each selected entry ID is passed to `entry_parse()`. The scan stops at the first valid visible original image.
+
+### Single/random FPDB query
+
+If the active query is internally marked `single`, the resolver uses the already selected entry with `peekEntry()` instead of performing another random/single query.
+
+### Search
+
+Search intentionally receives no content-specific image from this resolver; fallback image selection applies.
+
+## 4. Side-effect-free media probe
+
+The probe does **not** render actual image/gallery callbacks.
+
+```mermaid
+sequenceDiagram
+ participant SEO as SEO resolver
+ participant BB as Active BBCode parser
+ participant CLONE as Cloned parser
+ participant RM as ReadMore
+ participant RES as Media resolver
+
+ SEO->>BB: plugin_bbcode_init()
+ BB-->>SEO: active parser
+ SEO->>CLONE: clone parser
+ SEO->>CLONE: replace registered media callbacks with marker callbacks
+ SEO->>CLONE: parse(raw content)
+ CLONE-->>SEO: parsed text + ordered markers/tokens
+ alt stream and ReadMore active
+ SEO->>RM: plugin_readmore_get_stream_excerpt(parsed marker text)
+ RM-->>SEO: visible prefix
+ end
+ SEO->>RES: resolve markers in source order
+ RES-->>SEO: first valid original image metadata
+```
+
+Marker shape is internal and intentionally opaque:
+
+```text
+__FPSEOMEDIA____
+```
+
+The marker exists only to determine visibility/order. It is never emitted to the public page.
+
+## 5. ReadMore visibility
+
+In streams, `seometataginfo_find_first_content_image_meta()` calls `plugin_readmore_get_stream_excerpt()` when:
+
+- ReadMore exposes that helper;
+- stream visibility is being applied;
+- `$_GET['page']` is not set.
+
+The helper receives the probe output after BBCode parsing, matching the stage at which ReadMore normally operates.
+
+Modes preserved by ReadMore 1.0.4:
+
+| Mode | Behavior in current code |
+|---|---|
+| `manual` | cut at first `[more]` |
+| `auto` | cut when content length exceeds the threshold |
+| `semiauto` | historical order: auto behavior first, then manual branch only if auto did not cut |
+| `sentence` | cut according to sentence punctuation matches |
+
+The current default threshold in `plugin_readmore_get_stream_excerpt()` is `4`, preserving the patched ReadMore behavior of this snapshot.
+
+A token marker missing from the visible excerpt is treated as hidden. Once a hidden marker is encountered in stream mode, later media tokens are not considered.
+
+## 6. Single-image resolution
+
+`seometataginfo_content_image_meta()` distinguishes:
+
+The source resolver itself does not invent a description. For parsed `[img]` and `[photoswipeimage]` tokens, `seometataginfo_content_resolve_token()` separately reads only the explicit `title` attribute and normalizes it through `seometataginfo_content_normalize_image_alt()`. A BBCode `alt` attribute is intentionally not used as the Open Graph image description.
+
+### Remote HTTP(S)
+
+`seometataginfo_content_remote_image_meta()`:
+
+- trims and HTML-decodes the source;
+- converts a leading `www.` to `https://`;
+- rejects control characters;
+- requires `parse_url()` to produce both scheme and host;
+- accepts only `http` and `https`;
+- performs **no HTTP request**.
+
+Remote metadata has no local path, MIME, dimensions, mtime, or file size.
+
+### Local
+
+`seometataginfo_content_local_image_meta()`:
+
+1. normalizes the path with `seometataginfo_content_normalize_local_image_path()`;
+2. resolves both `IMAGES_DIR` and the file through `realpath()`;
+3. verifies containment inside the image root;
+4. requires a readable regular file;
+5. requires `getimagesize()` to recognize an image MIME type;
+6. returns public URL, MIME, dimensions, image type, mtime, size, relative path, and absolute path.
+
+The original image path is retained even if BBCode/Thumb would render a `.thumbs` preview.
+
+## 7. Local-path normalization
+
+Accepted local forms include the FlatPress image namespace, for example:
+
+```text
+images/photo.png
+fp-content/images/photo.png
+/flatpress/fp-content/images/photo.png # when BLOG_ROOT is /flatpress/
+```
+
+Normalization:
+
+- HTML-decodes the source;
+- converts backslashes to `/`;
+- rejects control characters;
+- rejects query strings and fragments;
+- strips repeated leading `./`;
+- strips a matching `BLOG_ROOT`;
+- removes empty and `.` path segments;
+- rejects any `..` segment;
+- maps `images/...` to `IMAGES_DIR`;
+- requires the normalized result to be inside the `IMAGES_DIR` namespace.
+
+Filesystem containment is validated again after `realpath()`.
+
+## 8. Gallery resolution
+
+`seometataginfo_content_gallery_meta()` accepts PhotoSwipe-style `images/` paths.
+
+Important behavior:
+
+- URL schemes are rejected;
+- `..` is rejected;
+- gallery filesystem path must resolve inside `IMAGES_DIR`;
+- the directory must exist;
+- `gallery_read_images()` is reused.
+
+This reuse is intentional. `gallery_read_images()`:
+
+- uses the FlatPress filesystem lister;
+- excludes `.captions.conf`, `captions.conf`, and legacy `texte.conf`;
+- sorts the remaining filenames with `sort()`.
+
+The SEO resolver then checks filenames in that exact order and returns the first file that resolves to valid image metadata. Invalid/non-image files can therefore be skipped without changing gallery ordering.
+
+## 9. `og:image:alt` resolution
+
+Image selection and image description are intentionally separate.
+
+### Single images
+
+For `[img]` and `[photoswipeimage]`:
+
+1. resolve and validate the image source;
+2. if the selected token contains an explicit scalar `title`, normalize it;
+3. store the normalized value in the internal image metadata `alt` field;
+4. if `title` is absent, empty, or normalizes to empty text, keep `alt` empty.
+
+The following do **not** become SEO fallbacks:
+
+- the BBCode `alt` attribute;
+- basename-derived renderer titles;
+- IPTC-derived titles;
+- thumbnail markup.
+
+### Gallery images
+
+`seometataginfo_content_gallery_meta()` first determines the first valid image using `gallery_read_images()` and the normal image validator. Only after that exact file has been selected does it read `gallery_read_captions()` and look up the caption by that filename.
+
+A missing caption never advances to a later image. This guarantees that image selection remains based on visible source order, not caption availability.
+
+### Text normalization and output fallback
+
+`seometataginfo_content_normalize_image_alt()`:
+
+- accepts only scalar input;
+- decodes HTML entities at most twice for compatibility with stored/legacy caption values;
+- removes markup;
+- replaces CR/LF with spaces;
+- trims the result;
+- leaves final HTML escaping to `output_metatags()`.
+
+`seometataginfo_prepare_og_image_meta()` preserves the internal `alt` value through both direct-URL and dynamic 1200 × 630 paths.
+
+`seometataginfo_get_og_image_alt_text()` then applies the final output rule:
+
+```text
+selected explicit image title / selected gallery caption
+ -> if non-empty: og:image:alt
+ -> otherwise: fp_config.general.title
+ -> if site title is also empty: "Preview"
+```
+
+## 10. Why thumbnails are never selected
+
+BBCode's `do_bbcode_img()` can call:
+
+```php
+apply_filters('bbcode_img_scale', $actualpath, $img_size, array($width, $height))
+```
+
+Thumb registers `plugin_thumb_bbcodehook()` on that filter and may create:
+
+```text
+/.thumbs/
+```
+
+The final `
` can therefore point to the preview rather than the original.
+
+The SEO pipeline avoids this by reading the BBCode tag attributes directly from the marker token and resolving that original source. It does not call `do_bbcode_img()` while probing.
+
+## 11. Dynamic 1200 × 630 endpoint
+
+For transformable local JPEG/PNG images, `seometataginfo_prepare_og_image_meta()` publishes a dynamic URL:
+
+```text
+index.php?seometa_ogimage=1&v=&seometa_ogsource=
+```
+
+The HTML meta tag escapes `&` as `&`, as required for HTML serialization.
+
+The crawler's later request is handled by:
+
+1. `plugin_seometataginfo_init()`
+2. `seometataginfo_is_og_image_request()`
+3. `seometataginfo_serve_og_image()`
+
+The explicit local source is revalidated for that independent request. The original page context is not required.
+
+## 12. Copied HTML URLs and `&`
+
+Browsers normally decode HTML entities before requesting a URL. A developer may nevertheless copy a literal source URL such as:
+
+```text
+?seometa_ogimage=1&v=1786722663&seometa_ogsource=fp-content%2Fimages%2Frepo-independent-og-source.png
+```
+
+Developer documentation and automated tests therefore use a neutral temporary fixture name and must not assume the presence of that instance-specific image.
+
+PHP parses the later keys as:
+
+```text
+amp;v
+amp;seometa_ogsource
+```
+
+`seometataginfo_get_query_parameter()` therefore supports aliases whose key begins with one or more literal `amp;` prefixes.
+
+Rules:
+
+- an exact query key always wins;
+- aliases are considered only when the exact key is absent;
+- repeated prefixes such as `amp;amp;seometa_ogsource` normalize to the requested name;
+- array values remain invalid;
+- source path validation is unchanged.
+
+This is tolerance for copied HTML source, not a relaxation of filesystem security.
+
+## 13. Explicit invalid source behavior
+
+`seometataginfo_get_requested_content_og_image_info()` returns both:
+
+- whether a content source was explicitly requested;
+- validated image metadata, if valid.
+
+`seometataginfo_serve_og_image()` only selects the theme preview when **no explicit content source was requested**.
+
+Therefore:
+
+- valid explicit source → serve that source;
+- invalid explicit source → 404 path;
+- no explicit source → style/theme preview, then bundled fallback.
+
+This distinction prevents a malformed or traversal-like content URL from silently returning a visually unrelated theme image.
+
+## 14. Transformability
+
+`seometataginfo_can_transform_og_image()` requires:
+
+- local absolute path;
+- recognized image type;
+- GD functions `imagecreatetruecolor`, `imagecopyresampled`, `imagefilledrectangle`;
+- JPEG: `imagecreatefromjpeg` + `imagejpeg`;
+- PNG: `imagecreatefrompng` + `imagepng`.
+
+Remote images and unsupported local formats remain direct URL fallbacks in normal metadata generation.
+
+## 15. Aspect-ratio-preserving render
+
+Default target:
+
+```text
+1200 × 630
+```
+
+`seometataginfo_calculate_og_contain_box()` computes:
+
+```text
+scale = min(targetWidth / sourceWidth, targetHeight / sourceHeight)
+```
+
+The same scale factor is applied to both axes.
+
+The scaled image is centered on a white target canvas.
+
+Examples from the regression definition:
+
+| Source | Fitted image | Offset |
+|---|---|---|
+| 1600×900 | 1120×630 | x=40, y=0 |
+| 900×1600 | 354×630 | x=423, y=0 |
+| 1000×1000 | 630×630 | x=285, y=0 |
+| 1200×630 | 1200×630 | x=0, y=0 |
+
+There is no stretching and no crop operation.
+
+```mermaid
+flowchart LR
+ S["Source W × H"] --> R["scale = min(1200 / W, 630 / H)"]
+ R --> D["Destination w = round(W × scale)
h = round(H × scale)"]
+ D --> C["Center on 1200 × 630 white canvas"]
+ C --> O["JPEG or PNG response"]
+```
+
+## 16. Failure paths
+
+A dynamic OG request can fail when:
+
+- the source parameter is invalid;
+- the file no longer exists;
+- the file moved outside `IMAGES_DIR`;
+- `getimagesize()` no longer recognizes the file;
+- image creation/resampling/output fails.
+
+The endpoint then returns 404 unless a valid source can still be streamed directly through `seometataginfo_output_image_file()`.
+
+A source-less dynamic request still uses the normal theme-preview/plugin-fallback source selection.
diff --git a/fp-plugins/seometataginfo/developer-docs/security.md b/fp-plugins/seometataginfo/developer-docs/security.md
new file mode 100644
index 00000000..1dbc64bb
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/security.md
@@ -0,0 +1,193 @@
+# Security Model
+
+## 1. Trust boundaries
+
+The plugin handles data from several trust domains:
+
+- administrator/editor form input;
+- persisted SEO INI files;
+- URL query parameters;
+- BBCode content written by site authors;
+- persisted Gallery captions written through the Gallery captions admin feature;
+- local filesystem image/gallery paths;
+- remote image URLs;
+- request headers used for conditional caching;
+- host/document-root configuration.
+
+The Open Graph endpoint is publicly reachable and must therefore treat its query parameters as untrusted.
+
+## 2. Local OG source containment
+
+The security model is intentionally layered.
+
+### Lexical normalization
+
+`seometataginfo_content_normalize_local_image_path()` rejects:
+
+- control characters;
+- `?` query data;
+- `#` fragments;
+- `..` segments.
+
+It normalizes separators and only permits the FlatPress image namespace.
+
+### Canonical filesystem validation
+
+`seometataginfo_content_local_image_meta()` resolves both the image root and candidate file with `realpath()`.
+
+`seometataginfo_content_path_is_within()` then requires the canonical candidate to equal the root or start with `/`.
+
+This protects against path traversal and also reduces symlink-based escape risk because comparison happens after canonicalization.
+
+### File and content validation
+
+The candidate must be:
+
+- a regular file;
+- readable;
+- recognized by `getimagesize()`;
+- reported with an `image/*` MIME.
+
+## 3. Gallery containment
+
+Gallery resolution performs its own normalization and canonical directory check before calling `gallery_read_images()`.
+
+Filename values returned by the gallery helper are also constrained:
+
+```php
+basename($file) === $file
+```
+
+A gallery item cannot inject a nested path into the subsequent image resolver.
+
+## 4. Remote image policy and SSRF
+
+Remote image metadata accepts only URLs with:
+
+- `http` or `https` scheme;
+- a host component;
+- no control characters.
+
+The resolver does **not** fetch the remote URL.
+
+This policy intentionally avoids SSRF, remote timeout, DNS, certificate, and bandwidth concerns in the SEO request path.
+
+Because the server does not inspect remote pixels, remote width/height/MIME remain unknown in this layer.
+
+## 5. Query-parameter alias tolerance
+
+Literal HTML entity prefixes are handled only at the **parameter-name** level.
+
+Example:
+
+```text
+amp;seometa_ogsource
+```
+
+can normalize to:
+
+```text
+seometa_ogsource
+```
+
+Security properties retained:
+
+- exact key has precedence;
+- array values are invalid;
+- normalized value still passes the full local-path validation;
+- invalid explicit content source does not fall back to a theme image.
+
+Repeated `amp;` handling exists for robustness when source HTML has been copied or double-escaped.
+
+## 6. Dynamic endpoint response behavior
+
+An explicit invalid content source results in the no-valid-image path and HTTP 404.
+
+This is safer and easier to debug than returning the theme preview, because a malformed source cannot be mistaken for a successfully served requested image.
+
+## 7. Parser side effects
+
+The SEO probe clones the BBCode parser and replaces media callbacks.
+
+Security/stability benefit:
+
+- it does not execute image/gallery rendering side effects merely because a crawler requests the page head;
+- it does not create thumbnails;
+- it does not advance PhotoSwipe's media index;
+- it retains parser restrictions, including code-block behavior.
+
+The temporary global marker context is restored in `finally`.
+
+## 8. Query side effects
+
+An independent `FPDB_Query` changes global query state by design.
+
+The SEO stream scan saves and restores:
+
+- `current_query`;
+- `post`.
+
+Do not remove the `try/finally` restoration during refactoring.
+
+## 9. Metadata form sanitization
+
+Editor-supplied description/keyword fields pass through `sanitizeSeoField()` before storage.
+
+Output also uses `htmlspecialchars()` or FlatPress escaping helpers in many metadata contexts.
+
+When adding new metadata values, preserve the distinction between:
+
+- storage sanitization;
+- HTML attribute escaping at output.
+
+Do not rely on only one of those layers for all contexts.
+
+The same output-layer rule applies to `og:image:alt`. BBCode `title` and Gallery captions are normalized to plain text by `seometataginfo_content_normalize_image_alt()` and are escaped with `htmlspecialchars()` only when the meta attribute is emitted. This avoids both markup injection and accidental double escaping of already entity-encoded captions.
+
+## 10. robots.txt write surface
+
+The robots admin panel writes to:
+
+```text
+$_SERVER['DOCUMENT_ROOT'] . '/robots.txt'
+```
+
+This is intentionally outside the FlatPress content tree in many installations.
+
+Requirements:
+
+- non-empty/valid `DOCUMENT_ROOT`;
+- writable document root for creation;
+- writable existing file for editing.
+
+Changes to this feature should be reviewed as host-level filesystem operations, not ordinary plugin-content writes.
+
+## 11. Canonical host handling
+
+`seometataginfo_build_public_url()` prefers the configured public base URL over raw request host reconstruction when possible.
+
+This helps avoid deriving public canonical URLs solely from request host data.
+
+The fallback helper `currentPageURL()` still exists for contexts where the configured base URL cannot be applied.
+
+## 12. Caching safety
+
+Image APCu identities include filesystem state such as mtime/size.
+
+HTTP cache identity includes path, dimensions, and mtime.
+
+Do not simplify these keys to user-provided source strings alone.
+
+## 13. Security regression cases that must remain
+
+At minimum preserve tests for:
+
+- local `..` traversal rejection;
+- traversal through literal `amp;seometa_ogsource`;
+- exact query parameter precedence over escaped aliases;
+- invalid explicit source not falling back to theme preview;
+- code-block content not becoming media;
+- remote image no-fetch behavior;
+- exact gallery-caption/file binding;
+- missing title/caption preserving image selection and using site-title fallback at output;
+- active query/global-state restoration.
diff --git a/fp-plugins/seometataginfo/developer-docs/testing.md b/fp-plugins/seometataginfo/developer-docs/testing.md
new file mode 100644
index 00000000..1f72cc04
--- /dev/null
+++ b/fp-plugins/seometataginfo/developer-docs/testing.md
@@ -0,0 +1,265 @@
+# Testing and Static Analysis
+
+## 1. Test assets
+
+The plugin contains four executable regression scripts:
+
+```text
+regression-test/
+├── simulate_og_content_image.php
+├── validate_og_image_format.php
+├── validate_target_parser.php
+└── compare_readmore_behavior.php
+```
+
+Generated JSON result files are also present, but the PHP scripts are the authoritative test definitions.
+
+## 2. Generated result artifacts
+
+In the patched snapshot, the checked-in result JSON files were refreshed after the regression run on PHP 8.4.23:
+
+- `simulate_og_content_image.php`: 49 assertions, 49 passed;
+- `validate_target_parser.php`: 7 assertions, 7 passed;
+- `compare_readmore_behavior.php`: 75 comparisons, 75 passed;
+- `validate_og_image_format.php`: 21 assertions, 21 passed.
+
+Total: **152/152 PASS**.
+
+The PHP scripts remain the authoritative test definitions. Result JSON files are generated artifacts and can become stale after a test script changes.
+
+Therefore:
+
+> Never infer the current regression matrix solely from checked-in `*-results.json`. Re-run the PHP scripts after changes and regenerate result artifacts when the project tracks them.
+
+## 3. Content-image simulation
+
+Run from the FlatPress root:
+
+```bash
+php fp-plugins/seometataginfo/regression-test/simulate_og_content_image.php
+```
+
+Current test definitions cover:
+
+- first original image in a single entry;
+- thumbnail exclusion;
+- transform-source metadata;
+- first gallery image in a single entry;
+- explicit single-image `title` propagation to internal OG alt metadata;
+- missing/empty single-image title staying empty for site-title fallback;
+- BBCode `alt` not substituting for a missing `title`;
+- entity decoding for image titles;
+- Gallery caption binding to the exact selected valid file;
+- missing first-image caption not selecting a later captioned image;
+- static image/gallery;
+- source ordering between gallery and image;
+- multiple images;
+- empty/invalid gallery fallthrough;
+- PhotoSwipe aliases;
+- invalid image then valid gallery;
+- non-image file inside gallery;
+- existing `.thumbs` preview;
+- `popup=false`;
+- remote image direct URL/no local transform source;
+- ReadMore manual/auto/semiauto/sentence visibility;
+- ReadMore disabled behavior;
+- single/static media after `[more]`;
+- `[code]` literal exclusion;
+- stream scan across multiple entries;
+- restoration of `current_query`;
+- hidden first-entry media falling through to a later entry;
+- PhotoSwipe disabled;
+- path traversal rejection;
+- `gallery_read_images()` ordering.
+
+The harness uses isolated fixtures rather than installed instance content.
+
+## 4. Target parser validation
+
+Run:
+
+```bash
+php fp-plugins/seometataginfo/regression-test/validate_target_parser.php
+```
+
+This loads the target BBCode/PhotoSwipe parser behavior and checks:
+
+- real `[img]` detection;
+- PhotoSwipe gallery detection;
+- legacy PhotoSwipe image/gallery aliases;
+- explicit `title` survives the actual PhotoSwipe-overridden `[img]` parser registration;
+- nested image inside `[code]` does not become media;
+- probe does not advance PhotoSwipe's internal image index.
+
+This test is particularly important after BBCode or PhotoSwipe parser changes.
+
+## 5. OG endpoint and 1200 × 630 validation
+
+Run:
+
+```bash
+php fp-plugins/seometataginfo/regression-test/validate_og_image_format.php
+```
+
+### Instance-only reproduction asset
+
+The regression therefore creates its own valid PNG under the temporary fixture `ABS_PATH` and exercises the same URL/query shape with the neutral name `repo-independent-og-source.png`. The test is valid on a clean repository checkout, on an installed instance with unrelated user images, and on CI without any user content.
+
+The **current script** includes assertions for:
+
+- transformable local PNG metadata;
+- dynamic content endpoint URL selection;
+- advertised 1200 × 630 dimensions;
+- image-description preservation through dynamic OG metadata preparation;
+- explicit image title preferred over site title;
+- site-title fallback when image title/caption is empty;
+- final `Preview` fallback only when the site title is also empty;
+- rehydration of validated content source;
+- proof that the HTML-entity query test uses a self-contained temporary fixture rather than repository/user content;
+- literal `&` query copied from HTML source, using a repository-independent temporary image fixture;
+- double escaped `&`;
+- escaped OG flag;
+- exact parameter precedence over escaped alias;
+- `BLOG_ROOT` path normalization;
+- traversal rejection through escaped alias;
+- normal traversal rejection;
+- contain geometry for 16:9 landscape;
+- contain geometry for portrait;
+- contain geometry for square;
+- contain geometry for already-1200×630 input;
+- remote image remains direct and is not server-fetched.
+
+When GD is absent in the CLI SAPI, the harness stubs only the missing functions needed to exercise transform-selection logic. Pixel rendering is not falsely claimed; geometry is tested as pure logic.
+
+## 6. ReadMore equivalence
+
+Usage:
+
+```bash
+php fp-plugins/seometataginfo/regression-test/compare_readmore_behavior.php \
+ /path/to/original-flatpress \
+ /path/to/patched-flatpress
+```
+
+The test launches isolated child PHP processes because `PLUGIN_READMORE_MODE` is a constant.
+
+Matrix:
+
+- modes: `manual`, `auto`, `semiauto`, `sentence`, invalid-mode fallback;
+- contexts: stream, single, stream with `page` parameter;
+- cases: short text, long text, manual marker, sentence sequence, image-like HTML.
+
+Total:
+
+```text
+5 modes × 3 contexts × 5 cases = 75 comparisons
+```
+
+The goal is byte-identical output between the chosen reference tree and the patched tree.
+
+## 7. PHPStan
+
+The repository workflow runs level 5 with:
+
+```bash
+php phpstan.phar analyse \
+ --configuration=.dist/phpstan.neon.dist \
+ --error-format=table
+```
+
+The provided local tree also contains `.dist/phpstan.phar`; when using that copy from the repository root:
+
+```bash
+php .dist/phpstan.phar analyse \
+ --configuration=.dist/phpstan.neon.dist \
+ --error-format=table
+```
+
+The configuration targets PHP 7.2–8.5. In this patched snapshot PHPStan 2.2.7 level 5 completed with **0 errors** before the regression run and again with **0 errors** afterward.
+
+### Regression stubs and symbol collisions
+
+Several regression harnesses intentionally define global FlatPress or GD stubs. If PHPStan scans those scripts as production symbol providers, their simplified signatures can shadow real FlatPress functions and generate misleading project-wide diagnostics.
+
+When maintaining PHPStan configuration:
+
+- analyze production code with production symbols;
+- do not let test-only stub declarations redefine production signatures;
+- exclude `fp-plugins/*/regression-test/**` from both production analysis and production symbol scanning;
+- still execute regression scripts separately.
+
+Do not suppress a genuine production diagnostic merely because test stubs also exist.
+
+## 8. Syntax checks
+
+A practical plugin-focused syntax pass:
+
+```bash
+find fp-plugins/seometataginfo \
+ fp-plugins/bbcode \
+ fp-plugins/photoswipe \
+ fp-plugins/thumb \
+ fp-plugins/readmore \
+ fp-plugins/gallerycaptions \
+ -name '*.php' -print0 |
+while IFS= read -r -d '' file; do
+ php -l "$file" || exit 1
+done
+```
+
+On shells without the same `find`/`read -d` behavior, use an equivalent platform-specific loop.
+
+## 9. Manual web smoke test
+
+After automated tests, verify a real installed instance.
+
+Recommended minimum cases:
+
+1. single entry with `[img="images/example.png"]`;
+2. single entry with `[gallery="images/example-gallery"]`;
+3. static page with image;
+4. stream where first visible media is in the second entry;
+5. stream where first entry's media is after `[more]`;
+6. a resized BBCode image with `.thumbs` present;
+7. `popup=false`;
+8. dynamic OG endpoint URL copied from rendered HTML;
+9. literal `&` copy of that endpoint URL;
+10. invalid/traversal source returns failure rather than theme preview;
+11. single image with `title` emits that text as `og:image:alt`;
+12. single image without `title` emits the configured site title as `og:image:alt`;
+13. gallery with a caption emits the selected file's caption;
+14. gallery without a caption retains the same selected image and falls back to the site title.
+
+Verify both:
+
+- ``;
+- the image bytes returned by the endpoint.
+
+## 10. Aspect-ratio verification
+
+For a transformable source, verify that the endpoint response is exactly the configured target canvas and that the source is not stretched.
+
+Current default target:
+
+```text
+1200 × 630
+```
+
+Because the implementation uses contain-fit, portrait/square images will have white side areas; very wide images can have white top/bottom areas.
+
+## 11. Release-quality checklist
+
+Before merging changes to the image subsystem:
+
+- PHPStan level 5 is clean for affected production scope;
+- all four regression scripts pass;
+- result JSONs are regenerated if they are intended to be versioned;
+- syntax checks pass;
+- a real web endpoint test passes;
+- no `.thumbs` URL becomes selected as `og:image`;
+- active query and PhotoSwipe state remain unchanged after probe;
+- invalid local source does not fall back to theme preview;
+- selected image title/caption is bound to the exact selected source;
+- absent image title/caption falls back to `general.title` without changing source selection;
+- remote URLs are not fetched;
+- PHP 7.2 syntax compatibility is preserved.
diff --git a/fp-plugins/seometataginfo/inc/og-content-image.php b/fp-plugins/seometataginfo/inc/og-content-image.php
new file mode 100644
index 00000000..1da79fef
--- /dev/null
+++ b/fp-plugins/seometataginfo/inc/og-content-image.php
@@ -0,0 +1,882 @@
+ '',
+ 'secure_url' => '',
+ 'mime' => '',
+ 'width' => 0,
+ 'height' => 0,
+ 'alt' => '',
+ 'relative_path' => '',
+ 'absolute_path' => '',
+ 'type' => 0,
+ 'mtime' => 0,
+ 'size_bytes' => 0
+ );
+}
+
+/**
+ * Normalize a user-provided image title/caption for og:image:alt.
+ *
+ * BBCode title attributes and Gallery captions values may already contain
+ * HTML entities. Decode at most twice (matching PhotoSwipe's compatibility
+ * handling), remove markup and line breaks, and leave final HTML escaping to
+ * the meta-tag output layer.
+ *
+ * @param mixed $value
+ * @return string
+ */
+function seometataginfo_content_normalize_image_alt($value) {
+ if (!is_scalar($value)) {
+ return '';
+ }
+
+ $charset = 'UTF-8';
+ if (isset($GLOBALS ['fp_config']) && is_array($GLOBALS ['fp_config'])
+ && isset($GLOBALS ['fp_config'] ['locale']) && is_array($GLOBALS ['fp_config'] ['locale'])
+ && isset($GLOBALS ['fp_config'] ['locale'] ['charset'])
+ && is_string($GLOBALS ['fp_config'] ['locale'] ['charset'])
+ && trim($GLOBALS ['fp_config'] ['locale'] ['charset']) !== '') {
+ $charset = strtoupper(trim($GLOBALS ['fp_config'] ['locale'] ['charset']));
+ }
+
+ $text = (string)$value;
+ for ($i = 0; $i < 2; $i++) {
+ $decoded = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, $charset);
+ if ($decoded === $text) {
+ break;
+ }
+ $text = $decoded;
+ }
+
+ $text = strip_tags($text);
+ $text = str_replace(array("\r", "\n"), ' ', $text);
+
+ return trim($text);
+}
+
+/**
+ * Remove a global key without teaching static analysis that the key is absent.
+ *
+ * FPDB_Query updates $GLOBALS['current_query'] as a constructor side effect,
+ * which PHPStan cannot infer from the caller.
+ *
+ * @param string $key
+ * @return void
+ */
+function seometataginfo_content_unset_global($key) {
+ unset($GLOBALS [$key]);
+}
+
+/**
+ * Store one media occurrence during a side-effect-free BBCode probe.
+ *
+ * @param string $tag
+ * @param string|null $action
+ * @param mixed $attributes
+ * @return string|true
+ */
+function seometataginfo_content_probe_media_callback($tag, $action, $attributes) {
+ if ($action === 'validate') {
+ return true;
+ }
+
+ if (!isset($GLOBALS ['seometataginfo_media_probe_context']) || !is_array($GLOBALS ['seometataginfo_media_probe_context'])) {
+ return '';
+ }
+
+ $context = &$GLOBALS ['seometataginfo_media_probe_context'];
+ $context ['counter'] = isset($context ['counter']) ? ((int)$context ['counter'] + 1) : 1;
+ $marker = '__FPSEOMEDIA_' . $context ['nonce'] . '_' . str_pad((string)$context ['counter'], 6, '0', STR_PAD_LEFT) . '__';
+
+ $context ['tokens'] [] = array(
+ 'tag' => strtolower((string)$tag),
+ 'attributes' => is_array($attributes) ? $attributes : array(),
+ 'marker' => $marker
+ );
+
+ return $marker;
+}
+
+function seometataginfo_content_probe_img($action, $attributes, $content, $params, $node_object) {
+ return seometataginfo_content_probe_media_callback('img', $action, $attributes);
+}
+
+function seometataginfo_content_probe_photoswipeimage($action, $attributes, $content, $params, $node_object) {
+ return seometataginfo_content_probe_media_callback('photoswipeimage', $action, $attributes);
+}
+
+function seometataginfo_content_probe_gallery($action, $attributes, $content, $params, $node_object) {
+ return seometataginfo_content_probe_media_callback('gallery', $action, $attributes);
+}
+
+function seometataginfo_content_probe_photoswipegallery($action, $attributes, $content, $params, $node_object) {
+ return seometataginfo_content_probe_media_callback('photoswipegallery', $action, $attributes);
+}
+
+/**
+ * Replace an existing BBCode definition on a cloned parser while preserving
+ * its callback parameters, content type, nesting rules and flags.
+ *
+ * @param object $parser
+ * @param string $tag
+ * @param string $callback
+ * @return bool
+ */
+function seometataginfo_content_probe_replace_code($parser, $tag, $callback) {
+ if (!is_object($parser) || !isset($parser->_codes) || !is_array($parser->_codes) || !isset($parser->_codes [$tag])) {
+ return false;
+ }
+
+ $definition = $parser->_codes [$tag];
+ if (!is_array($definition)) {
+ return false;
+ }
+
+ $required = array('callback_type', 'callback_params', 'content_type', 'allowed_within', 'not_allowed_within');
+ foreach ($required as $key) {
+ if (!array_key_exists($key, $definition)) {
+ return false;
+ }
+ }
+
+ $flags = isset($definition ['flags']) && is_array($definition ['flags']) ? $definition ['flags'] : array();
+
+ $parser->removeCode($tag);
+ $added = $parser->addCode(
+ $tag,
+ $definition ['callback_type'],
+ $callback,
+ $definition ['callback_params'],
+ $definition ['content_type'],
+ $definition ['allowed_within'],
+ $definition ['not_allowed_within']
+ );
+
+ if (!$added) {
+ return false;
+ }
+
+ foreach ($flags as $flag => $value) {
+ $parser->setCodeFlag($tag, $flag, $value);
+ }
+
+ return true;
+}
+
+/**
+ * Parse content with the active BBCode grammar, replacing only actually
+ * registered media tags by unique markers.
+ *
+ * @param string $content
+ * @return array{html:string,tokens:array}
+ */
+function seometataginfo_content_probe_media($content) {
+ $result = array(
+ 'html' => '',
+ 'tokens' => array()
+ );
+
+ $content = (string)$content;
+ if ($content === '' || !function_exists('plugin_bbcode_init')) {
+ return $result;
+ }
+
+ $baseParser = &plugin_bbcode_init();
+ if (!is_object($baseParser) || !method_exists($baseParser, 'parse')) {
+ return $result;
+ }
+
+ $parser = clone $baseParser;
+ $callbacks = array(
+ 'img' => 'seometataginfo_content_probe_img',
+ 'photoswipeimage' => 'seometataginfo_content_probe_photoswipeimage',
+ 'gallery' => 'seometataginfo_content_probe_gallery',
+ 'photoswipegallery' => 'seometataginfo_content_probe_photoswipegallery'
+ );
+
+ $replaced = 0;
+ foreach ($callbacks as $tag => $callback) {
+ if (seometataginfo_content_probe_replace_code($parser, $tag, $callback)) {
+ $replaced++;
+ }
+ }
+
+ if ($replaced < 1) {
+ return $result;
+ }
+
+ $hadPreviousContext = array_key_exists('seometataginfo_media_probe_context', $GLOBALS);
+ $previousContext = $hadPreviousContext ? $GLOBALS ['seometataginfo_media_probe_context'] : null;
+
+ $GLOBALS ['seometataginfo_media_probe_context'] = array(
+ 'counter' => 0,
+ 'nonce' => substr(sha1($content), 0, 12),
+ 'tokens' => array()
+ );
+
+ try {
+ $parsed = $parser->parse($content);
+ $result ['html'] = is_string($parsed) ? $parsed : '';
+ $probeContext = $GLOBALS ['seometataginfo_media_probe_context'];
+ $result ['tokens'] = $probeContext ['tokens'];
+ } finally {
+ if ($hadPreviousContext) {
+ $GLOBALS ['seometataginfo_media_probe_context'] = $previousContext;
+ } else {
+ unset($GLOBALS ['seometataginfo_media_probe_context']);
+ }
+ }
+
+ return $result;
+}
+
+/**
+ * Validate a remote image URL without fetching it.
+ *
+ * @param string $url
+ * @return array
+ */
+function seometataginfo_content_remote_image_meta($url) {
+ $empty = seometataginfo_content_empty_image_meta();
+ $url = trim((string)$url);
+ if ($url === '') {
+ return $empty;
+ }
+
+ $url = html_entity_decode($url, ENT_QUOTES, 'UTF-8');
+ if (strpos($url, 'www.') === 0) {
+ $url = 'https://' . $url;
+ }
+
+ if (preg_match('/[\x00-\x1F\x7F]/', $url)) {
+ return $empty;
+ }
+
+ $parts = parse_url($url);
+ if ($parts === false || empty($parts ['scheme']) || empty($parts ['host'])) {
+ return $empty;
+ }
+
+ $scheme = strtolower((string)$parts ['scheme']);
+ if ($scheme !== 'http' && $scheme !== 'https') {
+ return $empty;
+ }
+
+ return array(
+ 'url' => $url,
+ 'secure_url' => $scheme === 'https' ? $url : '',
+ 'mime' => '',
+ 'width' => 0,
+ 'height' => 0,
+ 'alt' => '',
+ 'relative_path' => '',
+ 'absolute_path' => '',
+ 'type' => 0,
+ 'mtime' => 0,
+ 'size_bytes' => 0
+ );
+}
+
+/**
+ * Return true when a canonical filesystem path is inside another canonical
+ * directory path.
+ *
+ * @param string $path
+ * @param string $root
+ * @return bool
+ */
+function seometataginfo_content_path_is_within($path, $root) {
+ $path = str_replace('\\', '/', (string)$path);
+ $root = rtrim(str_replace('\\', '/', (string)$root), '/');
+
+ if ($path === '' || $root === '') {
+ return false;
+ }
+
+ return $path === $root || strpos($path, $root . '/') === 0;
+}
+
+/**
+ * Normalize the source of a local [img] tag to the FlatPress images directory.
+ *
+ * @param string $source
+ * @return string
+ */
+function seometataginfo_content_normalize_local_image_path($source) {
+ $source = html_entity_decode(trim((string)$source), ENT_QUOTES, 'UTF-8');
+ $source = str_replace('\\', '/', $source);
+
+ if ($source === '' || preg_match('/[\x00-\x1F\x7F]/', $source)) {
+ return '';
+ }
+
+ // Query strings/fragments are not part of local FlatPress image filenames.
+ if (strpos($source, '?') !== false || strpos($source, '#') !== false) {
+ return '';
+ }
+
+ while (strpos($source, './') === 0) {
+ $source = substr($source, 2);
+ }
+
+ $source = ltrim($source, '/');
+
+ if (defined('BLOG_ROOT')) {
+ $blogRoot = trim(str_replace('\\', '/', (string)BLOG_ROOT), '/');
+ if ($blogRoot !== '' && strpos($source, $blogRoot . '/') === 0) {
+ $source = substr($source, strlen($blogRoot) + 1);
+ }
+ }
+
+ $segments = explode('/', $source);
+ $cleanSegments = array();
+ foreach ($segments as $segment) {
+ if ($segment === '' || $segment === '.') {
+ continue;
+ }
+ if ($segment === '..') {
+ return '';
+ }
+ $cleanSegments [] = $segment;
+ }
+ $source = implode('/', $cleanSegments);
+
+ if ($source === '') {
+ return '';
+ }
+
+ $imagesDir = defined('IMAGES_DIR') ? trim(str_replace('\\', '/', (string)IMAGES_DIR), '/') . '/' : 'fp-content/images/';
+ if (strpos($source, 'images/') === 0) {
+ return $imagesDir . substr($source, 7);
+ }
+
+ $imagesDirNoSlash = rtrim($imagesDir, '/');
+ if ($source === $imagesDirNoSlash || strpos($source, $imagesDir) === 0) {
+ return $source;
+ }
+
+ return '';
+}
+
+/**
+ * Resolve one local image to validated source metadata.
+ *
+ * The direct original URL is retained as a fallback, while filesystem/type
+ * metadata lets the SEO plugin publish the same source through its dynamic
+ * 1200x630 renderer when GD supports the format. The renderer itself performs
+ * proportional fitting, so this never changes which original image was chosen.
+ *
+ * @param string $source
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_content_local_image_meta($source, $baseUrl) {
+ $empty = seometataginfo_content_empty_image_meta();
+ $relativePath = seometataginfo_content_normalize_local_image_path($source);
+ if ($relativePath === '' || !defined('ABS_PATH')) {
+ return $empty;
+ }
+
+ $imagesDir = defined('IMAGES_DIR') ? (string)IMAGES_DIR : 'fp-content/images/';
+ $imagesRoot = @realpath(ABS_PATH . $imagesDir);
+ $absolutePath = @realpath(ABS_PATH . $relativePath);
+
+ if ($imagesRoot === false || $absolutePath === false) {
+ return $empty;
+ }
+ if (!seometataginfo_content_path_is_within($absolutePath, $imagesRoot)) {
+ return $empty;
+ }
+ if (!is_file($absolutePath) || !is_readable($absolutePath)) {
+ return $empty;
+ }
+
+ $size = @getimagesize($absolutePath);
+ if (!is_array($size) || empty($size [0]) || empty($size [1])) {
+ return $empty;
+ }
+
+ $mime = strtolower(trim((string)$size ['mime']));
+ if ($mime === '' || strpos($mime, 'image/') !== 0) {
+ return $empty;
+ }
+
+ $type = (int)$size [2];
+ $stat = @stat($absolutePath);
+ $mtime = is_array($stat) && isset($stat ['mtime']) ? (int)$stat ['mtime'] : 0;
+ $sizeBytes = is_array($stat) && isset($stat ['size']) ? (int)$stat ['size'] : 0;
+
+ $baseUrl = trim((string)$baseUrl);
+ if ($baseUrl === '' && defined('BLOG_BASEURL')) {
+ $baseUrl = (string)BLOG_BASEURL;
+ }
+
+ $url = $baseUrl === ''
+ ? $relativePath
+ : rtrim($baseUrl, '/') . '/' . ltrim($relativePath, '/');
+
+ return array(
+ 'url' => $url,
+ 'secure_url' => stripos($url, 'https://') === 0 ? $url : '',
+ 'mime' => $mime,
+ 'width' => (int)$size [0],
+ 'height' => (int)$size [1],
+ 'alt' => '',
+ 'relative_path' => $relativePath,
+ 'absolute_path' => $absolutePath,
+ 'type' => $type,
+ 'mtime' => $mtime,
+ 'size_bytes' => $sizeBytes
+ );
+}
+
+/**
+ * Resolve an [img]/[photoswipeimage] source.
+ *
+ * @param string $source
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_content_image_meta($source, $baseUrl) {
+ $source = trim((string)$source);
+ if ($source === '') {
+ return seometataginfo_content_empty_image_meta();
+ }
+
+ $decoded = html_entity_decode($source, ENT_QUOTES, 'UTF-8');
+ if (preg_match('~^(?:https?://|www\.)~i', $decoded)) {
+ return seometataginfo_content_remote_image_meta($decoded);
+ }
+
+ return seometataginfo_content_local_image_meta($decoded, $baseUrl);
+}
+
+/**
+ * Rehydrate an explicitly requested local content source for the dynamic
+ * Open Graph image endpoint.
+ *
+ * The query helper also accepts literal HTML-escaped parameter names such as
+ * "amp;seometa_ogsource". Presence is kept separate from validity so an
+ * explicit invalid source cannot silently fall back to the theme preview.
+ *
+ * @param string $baseUrl
+ * @return array{requested:bool,image_info:array}
+ */
+function seometataginfo_get_requested_content_og_image_info($baseUrl) {
+ $result = array(
+ 'requested' => false,
+ 'image_info' => array()
+ );
+
+ $parameter = seometataginfo_get_query_parameter(SEOMETA_OGIMAGE_SOURCE_QUERY_VAR);
+ if (empty($parameter ['present'])) {
+ return $result;
+ }
+
+ $result ['requested'] = true;
+ if (empty($parameter ['valid'])) {
+ return $result;
+ }
+
+ $source = seometataginfo_content_normalize_local_image_path($parameter ['value']);
+ if ($source === '') {
+ return $result;
+ }
+
+ $imageInfo = seometataginfo_content_local_image_meta($source, $baseUrl);
+ if (empty($imageInfo ['absolute_path']) || empty($imageInfo ['mime'])) {
+ return $result;
+ }
+
+ $result ['image_info'] = $imageInfo;
+ return $result;
+}
+
+/**
+ * Resolve the first valid original image in a PhotoSwipe gallery.
+ *
+ * gallery_read_images() is intentionally reused so OG and PhotoSwipe share the
+ * same gallery sorting and caption-file exclusion rules.
+ *
+ * @param string $source
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_content_gallery_meta($source, $baseUrl) {
+ $empty = seometataginfo_content_empty_image_meta();
+ $source = html_entity_decode(trim((string)$source), ENT_QUOTES, 'UTF-8');
+ $source = str_replace('\\', '/', $source);
+
+ if ($source === '' || preg_match('/[\x00-\x1F\x7F]/', $source) || preg_match('~^[a-z][a-z0-9+.-]*:~i', $source)) {
+ return $empty;
+ }
+
+ $source = ltrim($source, '/');
+ while (strpos($source, './') === 0) {
+ $source = substr($source, 2);
+ }
+
+ $segments = explode('/', $source);
+ $clean = array();
+ foreach ($segments as $segment) {
+ if ($segment === '' || $segment === '.') {
+ continue;
+ }
+ if ($segment === '..') {
+ return $empty;
+ }
+ $clean [] = $segment;
+ }
+ $source = implode('/', $clean);
+
+ // PhotoSwipe's gallery implementation is defined for images/.
+ if ($source === '' || strpos($source, 'images/') !== 0 || !function_exists('gallery_read_images')) {
+ return $empty;
+ }
+
+ $galleryDir = rtrim($source, '/') . '/';
+ if (!defined('ABS_PATH') || !defined('IMAGES_DIR')) {
+ return $empty;
+ }
+
+ $imagesRoot = @realpath(ABS_PATH . IMAGES_DIR);
+ $galleryRelative = IMAGES_DIR . substr(rtrim($source, '/'), 7);
+ $galleryAbsolute = @realpath(ABS_PATH . $galleryRelative);
+
+ if ($imagesRoot === false || $galleryAbsolute === false || !is_dir($galleryAbsolute)) {
+ return $empty;
+ }
+ if (!seometataginfo_content_path_is_within($galleryAbsolute, $imagesRoot)) {
+ return $empty;
+ }
+
+ $imageFiles = gallery_read_images($galleryDir);
+ if (!is_array($imageFiles)) {
+ return $empty;
+ }
+
+ foreach ($imageFiles as $file) {
+ if (!is_scalar($file)) {
+ continue;
+ }
+ $file = (string)$file;
+ if ($file === '' || basename($file) !== $file) {
+ continue;
+ }
+
+ $meta = seometataginfo_content_image_meta($galleryDir . $file, $baseUrl);
+ if (empty($meta ['url'])) {
+ continue;
+ }
+
+ // Bind the caption to the exact valid image selected above. A missing
+ // caption never changes image selection; output_metatags() will fall back
+ // to the configured site title.
+ if (function_exists('gallery_read_captions')) {
+ $captions = gallery_read_captions($galleryDir);
+ if (is_array($captions) && array_key_exists($file, $captions)) {
+ $meta ['alt'] = seometataginfo_content_normalize_image_alt($captions [$file]);
+ }
+ }
+
+ return $meta;
+ }
+
+ return $empty;
+}
+
+/**
+ * Resolve one media token collected by the BBCode probe.
+ *
+ * @param array $token
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_content_resolve_token($token, $baseUrl) {
+ $empty = seometataginfo_content_empty_image_meta();
+ if (!is_array($token) || empty($token ['tag']) || empty($token ['attributes']) || !is_array($token ['attributes'])) {
+ return $empty;
+ }
+
+ $attributes = $token ['attributes'];
+ if (!isset($attributes ['default']) || !is_scalar($attributes ['default'])) {
+ return $empty;
+ }
+
+ $source = trim((string)$attributes ['default']);
+ if ($source === '') {
+ return $empty;
+ }
+
+ $tag = strtolower((string)$token ['tag']);
+ if ($tag === 'gallery' || $tag === 'photoswipegallery') {
+ return seometataginfo_content_gallery_meta($source, $baseUrl);
+ }
+ if ($tag === 'img' || $tag === 'photoswipeimage') {
+ $meta = seometataginfo_content_image_meta($source, $baseUrl);
+ if (!empty($meta ['url']) && array_key_exists('title', $attributes)) {
+ $meta ['alt'] = seometataginfo_content_normalize_image_alt($attributes ['title']);
+ }
+ return $meta;
+ }
+
+ return $empty;
+}
+
+/**
+ * Find the first valid original image in one content string.
+ *
+ * @param string $content
+ * @param string $baseUrl
+ * @param bool $applyReadMore Whether stream visibility rules must be applied
+ * @return array
+ */
+function seometataginfo_find_first_content_image_meta($content, $baseUrl, $applyReadMore) {
+ $empty = seometataginfo_content_empty_image_meta();
+ $probe = seometataginfo_content_probe_media((string)$content);
+
+ if (empty($probe ['tokens']) || !is_array($probe ['tokens'])) {
+ return $empty;
+ }
+
+ $visibleProbe = isset($probe ['html']) && is_string($probe ['html']) ? $probe ['html'] : '';
+ if ($applyReadMore && !isset($_GET ['page']) && function_exists('plugin_readmore_get_stream_excerpt')) {
+ $excerpt = plugin_readmore_get_stream_excerpt($visibleProbe);
+ if (is_array($excerpt) && isset($excerpt ['content'])) {
+ $visibleProbe = (string)$excerpt ['content'];
+ }
+ }
+
+ foreach ($probe ['tokens'] as $token) {
+ if (!is_array($token) || empty($token ['marker'])) {
+ continue;
+ }
+
+ // Tokens are collected in parser/source order. Once ReadMore removed a
+ // marker, all following media occurrences are outside the visible prefix.
+ if (strpos($visibleProbe, (string)$token ['marker']) === false) {
+ if ($applyReadMore) {
+ break;
+ }
+ continue;
+ }
+
+ $meta = seometataginfo_content_resolve_token($token, $baseUrl);
+ if (!empty($meta ['url'])) {
+ return $meta;
+ }
+ }
+
+ return $empty;
+}
+
+/**
+ * Read the raw content of the current static page.
+ *
+ * @return string
+ */
+function seometataginfo_get_current_static_content() {
+ global $smarty, $fp_params, $fp_config;
+
+ if (isset($smarty) && is_object($smarty) && method_exists($smarty, 'getTemplateVars')) {
+ $page = $smarty->getTemplateVars('static_page');
+ if (is_array($page) && isset($page ['content'])) {
+ return (string)$page ['content'];
+ }
+ }
+
+ $id = '';
+ if (!empty($fp_params ['page']) && is_scalar($fp_params ['page'])) {
+ $id = (string)$fp_params ['page'];
+ } elseif (empty($fp_params) && !empty($fp_config ['general'] ['startpage']) && is_scalar($fp_config ['general'] ['startpage'])) {
+ $id = (string)$fp_config ['general'] ['startpage'];
+ }
+
+ if ($id !== '' && function_exists('static_parse')) {
+ $page = static_parse($id);
+ if (is_array($page) && isset($page ['content'])) {
+ return (string)$page ['content'];
+ }
+ }
+
+ return '';
+}
+
+/**
+ * Copy the active stream window into a lightweight independent FPDB query.
+ *
+ * @param object $query
+ * @return array
+ */
+function seometataginfo_get_stream_query_params($query) {
+ if (!is_object($query) || !isset($query->params) || !is_object($query->params)) {
+ return array();
+ }
+
+ $p = $query->params;
+ $params = array(
+ 'fullparse' => false,
+ 'start' => isset($p->start) ? (int)$p->start : 0,
+ 'count' => isset($p->count) ? (int)$p->count : 0
+ );
+
+ foreach (array('y', 'm', 'd') as $key) {
+ if (isset($p->{$key}) && $p->{$key} !== null && $p->{$key} !== '') {
+ $params [$key] = $p->{$key};
+ }
+ }
+ if (isset($p->category) && (int)$p->category !== 0) {
+ $params ['category'] = (int)$p->category;
+ }
+ if (isset($p->exclude) && $p->exclude !== '') {
+ $params ['exclude'] = (int)$p->exclude;
+ }
+
+ return $params;
+}
+
+/**
+ * Scan the current multi-entry query without consuming its iterator.
+ *
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_get_stream_content_image_meta($baseUrl) {
+ global $fpdb;
+
+ $empty = seometataginfo_content_empty_image_meta();
+ if (!isset($fpdb) || !is_object($fpdb) || !method_exists($fpdb, 'getQuery')) {
+ return $empty;
+ }
+
+ $query = &$fpdb->getQuery();
+ if (!is_object($query)) {
+ return $empty;
+ }
+
+ // Random/id queries are single internally. They are not chopped by ReadMore,
+ // so inspect exactly the already selected entry rather than choosing another
+ // random item in a second query.
+ if (!empty($query->single)) {
+ if (method_exists($query, 'peekEntry')) {
+ $peek = @$query->peekEntry();
+ if (is_array($peek) && !empty($peek [0]) && function_exists('entry_parse')) {
+ $entry = entry_parse($peek [0]);
+ if (is_array($entry) && isset($entry ['content'])) {
+ return seometataginfo_find_first_content_image_meta($entry ['content'], $baseUrl, false);
+ }
+ }
+ }
+ return $empty;
+ }
+
+ if (!class_exists('FPDB_Query') || !function_exists('entry_parse')) {
+ return $empty;
+ }
+
+ $params = seometataginfo_get_stream_query_params($query);
+ if (empty($params) || !isset($params ['count']) || (int)$params ['count'] === 0) {
+ return $empty;
+ }
+
+ $hadCurrentQuery = array_key_exists('current_query', $GLOBALS);
+ $savedCurrentQuery = $hadCurrentQuery ? $GLOBALS ['current_query'] : null;
+ $hadPost = array_key_exists('post', $GLOBALS);
+ $savedPost = $hadPost ? $GLOBALS ['post'] : null;
+ $result = $empty;
+
+ try {
+ $scan = new FPDB_Query($params, -2147483000);
+
+ while ($scan->hasMore()) {
+ $couplet = &$scan->getEntry();
+ if (!is_array($couplet) || empty($couplet [0])) {
+ break;
+ }
+
+ $entry = entry_parse($couplet [0]);
+ if (!is_array($entry) || !isset($entry ['content'])) {
+ continue;
+ }
+
+ $meta = seometataginfo_find_first_content_image_meta($entry ['content'], $baseUrl, true);
+ if (!empty($meta ['url'])) {
+ $result = $meta;
+ break;
+ }
+ }
+ } finally {
+ if ($hadCurrentQuery) {
+ $GLOBALS ['current_query'] = $savedCurrentQuery;
+ } else {
+ seometataginfo_content_unset_global('current_query');
+ }
+
+ if ($hadPost) {
+ $GLOBALS ['post'] = $savedPost;
+ } else {
+ seometataginfo_content_unset_global('post');
+ }
+ }
+
+ return $result;
+}
+
+/**
+ * Resolve a content image for the current FlatPress request.
+ *
+ * Priority inside this layer follows what the visitor sees:
+ * - static page: its complete content
+ * - single entry: its complete content (including content after [more])
+ * - stream: entries in query order, respecting ReadMore visibility
+ *
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_get_content_og_image_meta($baseUrl) {
+ $empty = seometataginfo_content_empty_image_meta();
+
+ if (!function_exists('plugin_bbcode_init')) {
+ return $empty;
+ }
+
+ if (function_exists('is_static') && is_static()) {
+ $content = seometataginfo_get_current_static_content();
+ return seometataginfo_find_first_content_image_meta($content, $baseUrl, false);
+ }
+
+ if (function_exists('is_single') && is_single()) {
+ if (function_exists('seometataginfo_get_current_single_entry_data')) {
+ $data = seometataginfo_get_current_single_entry_data();
+ if (is_array($data) && !empty($data ['entry']) && is_array($data ['entry']) && isset($data ['entry'] ['content'])) {
+ return seometataginfo_find_first_content_image_meta($data ['entry'] ['content'], $baseUrl, false);
+ }
+ }
+ return $empty;
+ }
+
+ // FlatPress search uses its own result collection rather than the ordinary
+ // FPDB stream window. Avoid publishing an unrelated image in that context.
+ if (function_exists('is_search') && is_search()) {
+ return $empty;
+ }
+
+ return seometataginfo_get_stream_content_image_meta($baseUrl);
+}
+?>
diff --git a/fp-plugins/seometataginfo/plugin.seometataginfo.php b/fp-plugins/seometataginfo/plugin.seometataginfo.php
index 38a63d9f..3784cae0 100644
--- a/fp-plugins/seometataginfo/plugin.seometataginfo.php
+++ b/fp-plugins/seometataginfo/plugin.seometataginfo.php
@@ -10,6 +10,7 @@
// SEE 'readme.txt' for information
require ('inc/hw-helpers.php');
+require ('inc/og-content-image.php');
require ('inc/class.iniparser.php');
require ('inc/migrate_data.php');
@@ -97,6 +98,10 @@
define('SEOMETA_GEN_IMAGE_META', true);
}
+/**
+ * On most social media platforms, images must be at least 200x200 pixels.
+ * Recommended aspect ratio: 1.91:1, for example, 1200 x 630 pixels
+ */
if (!defined('SEOMETA_OGIMAGE_TARGET_WIDTH')) {
define('SEOMETA_OGIMAGE_TARGET_WIDTH', 1200);
}
@@ -109,6 +114,16 @@
define('SEOMETA_OGIMAGE_QUERY_VAR', 'seometa_ogimage');
}
+/**
+ * Optional local content-image source for the dynamic OG endpoint.
+ *
+ * Only paths validated by seometataginfo_content_normalize_local_image_path()
+ * are accepted. Theme/style previews keep using the source-less endpoint.
+ */
+if (!defined('SEOMETA_OGIMAGE_SOURCE_QUERY_VAR')) {
+ define('SEOMETA_OGIMAGE_SOURCE_QUERY_VAR', 'seometa_ogsource');
+}
+
if (!defined('SEOMETA_OGIMAGE_FALLBACK_RELATIVE_PATH')) {
define('SEOMETA_OGIMAGE_FALLBACK_RELATIVE_PATH', 'fp-plugins/seometataginfo/imgs/og-image.png');
}
@@ -385,6 +400,29 @@ function __construct() {
new plugin_seometatags_entry();
}
+/**
+ * Resolve the Open Graph image description.
+ *
+ * Content images carry a normalized user title/caption in the internal `alt`
+ * field. If it is missing or empty, fall back to the configured site title.
+ * "Preview" remains the final technical fallback for an empty site title.
+ *
+ * @param array $imageMeta
+ * @param mixed $siteTitle
+ * @return string
+ */
+function seometataginfo_get_og_image_alt_text($imageMeta, $siteTitle) {
+ if (is_array($imageMeta) && isset($imageMeta ['alt']) && is_scalar($imageMeta ['alt'])) {
+ $alt = trim((string)$imageMeta ['alt']);
+ if ($alt !== '') {
+ return $alt;
+ }
+ }
+
+ $siteTitle = is_scalar($siteTitle) ? trim((string)$siteTitle) : '';
+ return $siteTitle !== '' ? $siteTitle : 'Preview';
+}
+
function output_metatags($seo_desc, $seo_keywords, $seo_noindex, $seo_nofollow, $seo_noarchive, $seo_nosnippet) {
global $prepend_description;
global $prepend_keywords;
@@ -396,7 +434,7 @@ function output_metatags($seo_desc, $seo_keywords, $seo_noindex, $seo_nofollow,
$lang = $fp_config ['locale'] ['lang'] ?? '';
$site_title = $fp_config ['general'] ['title'] ?? '';
$BLOG_BASEURL = $fp_config ['general'] ['www'] ?? '';
- $ogImageMeta = array('url' => '', 'secure_url' => '', 'mime' => '', 'width' => 0, 'height' => 0);
+ $ogImageMeta = array('url' => '', 'secure_url' => '', 'mime' => '', 'width' => 0, 'height' => 0, 'alt' => '');
if (seometataginfo_flag('SEOMETA_GEN_IMAGE_META') && seometataginfo_flag('SEOMETA_GEN_OPEN_GRAPH')) {
$ogImageMeta = seometataginfo_get_og_image_meta($BLOG_BASEURL);
}
@@ -421,7 +459,8 @@ function output_metatags($seo_desc, $seo_keywords, $seo_noindex, $seo_nofollow,
if (!empty($ogImageMeta ['mime'])) {
echo ' ' . "\n";
}
- echo ' ' . "\n";
+ $ogImageAlt = seometataginfo_get_og_image_alt_text($ogImageMeta, $site_title);
+ echo ' ' . "\n";
if (!empty($ogImageMeta ['width'])) {
echo ' ' . "\n";
}
@@ -534,6 +573,77 @@ function seometataginfo_append_query_args($url, $args) {
return $url . (strpos($url, '?') === false ? '?' : '&') . $query . $fragment;
}
+/**
+ * Normalize a query parameter name that was copied from HTML source with an
+ * encoded ampersand separator.
+ *
+ * Example:
+ * ?seometa_ogimage=1&seometa_ogsource=...
+ *
+ * When such a URL is requested literally, PHP exposes the second key as
+ * "amp;seometa_ogsource". Browsers/crawlers normally decode HTML entities
+ * before requesting the URL, but accepting the literal form makes the
+ * endpoint robust for copied source URLs and similarly double-escaped forms.
+ *
+ * @param string $name
+ * @return string
+ */
+function seometataginfo_normalize_query_parameter_name($name) {
+ $name = trim((string)$name);
+ while (stripos($name, 'amp;') === 0) {
+ $name = substr($name, 4);
+ }
+ return $name;
+}
+
+/**
+ * Read one scalar query parameter without mutating the global request.
+ *
+ * The exact key always has precedence. If it is absent, keys prefixed by one
+ * or more literal "amp;" fragments are considered aliases. Array values stay
+ * invalid, matching the previous request-validation behavior.
+ *
+ * @param string $name
+ * @return array{present:bool,valid:bool,value:string}
+ */
+function seometataginfo_get_query_parameter($name) {
+ $result = array(
+ 'present' => false,
+ 'valid' => false,
+ 'value' => ''
+ );
+
+ $name = trim((string)$name);
+ if ($name === '') {
+ return $result;
+ }
+
+ if (array_key_exists($name, $_GET)) {
+ $result ['present'] = true;
+ $value = $_GET [$name];
+ if (!is_array($value)) {
+ $result ['valid'] = true;
+ $result ['value'] = (string)$value;
+ }
+ return $result;
+ }
+
+ foreach ($_GET as $key => $value) {
+ if (!is_string($key) || seometataginfo_normalize_query_parameter_name($key) !== $name) {
+ continue;
+ }
+
+ $result ['present'] = true;
+ if (!is_array($value)) {
+ $result ['valid'] = true;
+ $result ['value'] = (string)$value;
+ }
+ return $result;
+ }
+
+ return $result;
+}
+
function seometataginfo_get_runtime_config() {
global $fp_config;
@@ -912,7 +1022,20 @@ function seometataginfo_can_transform_og_image($imageInfo) {
return false;
}
-function seometataginfo_build_og_image_url($baseUrl, $imageInfo) {
+/**
+ * Build the public URL for the dynamic 1200x630 OG-image endpoint.
+ *
+ * A validated content-image path can be included so the later crawler request
+ * can reproduce the selected source without needing the original page context.
+ * Theme/style previews deliberately omit this parameter and retain the
+ * historical source-selection path.
+ *
+ * @param string $baseUrl
+ * @param array $imageInfo
+ * @param string $contentSource Relative local content-image path or empty string
+ * @return string
+ */
+function seometataginfo_build_og_image_url($baseUrl, $imageInfo, $contentSource = '') {
$entryPoint = seometataginfo_url_join($baseUrl, 'index.php');
if ($entryPoint === '') {
return '';
@@ -923,42 +1046,58 @@ function seometataginfo_build_og_image_url($baseUrl, $imageInfo) {
$version = (string)(int)$imageInfo ['mtime'];
}
- return seometataginfo_append_query_args($entryPoint, array(
+ $args = array(
SEOMETA_OGIMAGE_QUERY_VAR => '1',
'v' => $version
- ));
+ );
+
+ $contentSource = seometataginfo_content_normalize_local_image_path($contentSource);
+ if ($contentSource !== '') {
+ $args [SEOMETA_OGIMAGE_SOURCE_QUERY_VAR] = $contentSource;
+ }
+
+ return seometataginfo_append_query_args($entryPoint, $args);
}
/**
- * Returns the effective og:image metadata for the current request.
+ * Convert validated source metadata to public OG metadata.
*
- * When GD is available we publish a dynamic 1200x630 endpoint that renders
- * the transformed image fully in memory. Otherwise we keep the original image.
+ * JPEG/PNG sources are published through the existing 1200x630 endpoint when
+ * GD support is available. The renderer uses a proportional "contain" fit, so
+ * source pixels are never stretched. Unsupported/remote images retain the
+ * direct-URL fallback rather than being downloaded or proxied.
*
* @param string $baseUrl
+ * @param array $imageInfo
+ * @param string $contentSource
* @return array
*/
-function seometataginfo_get_og_image_meta($baseUrl) {
- $imageInfo = seometataginfo_get_og_image_source_info($baseUrl);
- if (empty($imageInfo ['url'])) {
+function seometataginfo_prepare_og_image_meta($baseUrl, $imageInfo, $contentSource = '') {
+ $alt = is_array($imageInfo) && isset($imageInfo ['alt']) && is_scalar($imageInfo ['alt'])
+ ? (string)$imageInfo ['alt']
+ : '';
+
+ if (!is_array($imageInfo) || empty($imageInfo ['url'])) {
return array(
'url' => '',
'secure_url' => '',
'mime' => '',
'width' => 0,
'height' => 0,
+ 'alt' => '',
);
}
if (seometataginfo_can_transform_og_image($imageInfo)) {
- $dynamicUrl = seometataginfo_build_og_image_url($baseUrl, $imageInfo);
+ $dynamicUrl = seometataginfo_build_og_image_url($baseUrl, $imageInfo, $contentSource);
if ($dynamicUrl !== '') {
return array(
'url' => $dynamicUrl,
'secure_url' => (stripos($dynamicUrl, 'https://') === 0) ? $dynamicUrl : '',
- 'mime' => (string)$imageInfo ['mime'],
+ 'mime' => isset($imageInfo ['mime']) ? (string)$imageInfo ['mime'] : '',
'width' => (int)SEOMETA_OGIMAGE_TARGET_WIDTH,
'height' => (int)SEOMETA_OGIMAGE_TARGET_HEIGHT,
+ 'alt' => $alt,
);
}
}
@@ -967,23 +1106,42 @@ function seometataginfo_get_og_image_meta($baseUrl) {
return array(
'url' => $url,
'secure_url' => (stripos($url, 'https://') === 0) ? $url : '',
- 'mime' => (string)$imageInfo ['mime'],
- 'width' => (int)$imageInfo ['width'],
- 'height' => (int)$imageInfo ['height'],
+ 'mime' => isset($imageInfo ['mime']) ? (string)$imageInfo ['mime'] : '',
+ 'width' => isset($imageInfo ['width']) ? (int)$imageInfo ['width'] : 0,
+ 'height' => isset($imageInfo ['height']) ? (int)$imageInfo ['height'] : 0,
+ 'alt' => $alt,
);
}
-function seometataginfo_is_og_image_request() {
- if (!isset($_GET [SEOMETA_OGIMAGE_QUERY_VAR])) {
- return false;
+/**
+ * Returns the effective og:image metadata for the current request.
+ *
+ * Local entry/static/gallery images use the same dynamic 1200x630 renderer as
+ * the style preview. The renderer preserves the source aspect ratio and centers
+ * the scaled image on the target canvas. Remote images remain direct URLs to
+ * avoid server-side downloads/SSRF behavior.
+ *
+ * @param string $baseUrl
+ * @return array
+ */
+function seometataginfo_get_og_image_meta($baseUrl) {
+ $contentImageMeta = seometataginfo_get_content_og_image_meta($baseUrl);
+ if (!empty($contentImageMeta ['url'])) {
+ $contentSource = isset($contentImageMeta ['relative_path']) ? (string)$contentImageMeta ['relative_path'] : '';
+ return seometataginfo_prepare_og_image_meta($baseUrl, $contentImageMeta, $contentSource);
}
- $value = $_GET [SEOMETA_OGIMAGE_QUERY_VAR];
- if (is_array($value)) {
+ $imageInfo = seometataginfo_get_og_image_source_info($baseUrl);
+ return seometataginfo_prepare_og_image_meta($baseUrl, $imageInfo);
+}
+
+function seometataginfo_is_og_image_request() {
+ $parameter = seometataginfo_get_query_parameter(SEOMETA_OGIMAGE_QUERY_VAR);
+ if (empty($parameter ['present']) || empty($parameter ['valid'])) {
return false;
}
- $value = trim((string)$value);
+ $value = trim((string)$parameter ['value']);
return in_array($value, array('', '1', 'true', 'yes'), true);
}
@@ -1097,6 +1255,36 @@ function seometataginfo_destroy_image_resource(&$image) {
$image = null;
}
+/**
+ * Calculate a centered proportional "contain" rectangle.
+ *
+ * The same scale factor is applied to both axes; therefore no source image is
+ * stretched or squashed when placed on the 1.91:1 OG canvas.
+ *
+ * @param int $sourceWidth
+ * @param int $sourceHeight
+ * @param int $targetWidth
+ * @param int $targetHeight
+ * @return array{x:int,y:int,width:int,height:int}
+ */
+function seometataginfo_calculate_og_contain_box($sourceWidth, $sourceHeight, $targetWidth, $targetHeight) {
+ $sourceWidth = max(1, (int)$sourceWidth);
+ $sourceHeight = max(1, (int)$sourceHeight);
+ $targetWidth = max(1, (int)$targetWidth);
+ $targetHeight = max(1, (int)$targetHeight);
+
+ $scale = min($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
+ $destWidth = max(1, (int)round($sourceWidth * $scale));
+ $destHeight = max(1, (int)round($sourceHeight * $scale));
+
+ return array(
+ 'x' => (int)floor(($targetWidth - $destWidth) / 2),
+ 'y' => (int)floor(($targetHeight - $destHeight) / 2),
+ 'width' => $destWidth,
+ 'height' => $destHeight
+ );
+}
+
function seometataginfo_render_og_image($imageInfo, $targetWidth, $targetHeight) {
$targetWidth = max(1, (int)$targetWidth);
$targetHeight = max(1, (int)$targetHeight);
@@ -1129,13 +1317,20 @@ function seometataginfo_render_og_image($imageInfo, $targetWidth, $targetHeight)
$background = imagecolorallocate($canvas, 255, 255, 255);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $background);
- $scale = min($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
- $destWidth = max(1, (int)round($sourceWidth * $scale));
- $destHeight = max(1, (int)round($sourceHeight * $scale));
- $destX = (int)floor(($targetWidth - $destWidth) / 2);
- $destY = (int)floor(($targetHeight - $destHeight) / 2);
-
- $copied = imagecopyresampled($canvas, $source, $destX, $destY, 0, 0, $destWidth, $destHeight, $sourceWidth, $sourceHeight);
+ $box = seometataginfo_calculate_og_contain_box($sourceWidth, $sourceHeight, $targetWidth, $targetHeight);
+
+ $copied = imagecopyresampled(
+ $canvas,
+ $source,
+ $box ['x'],
+ $box ['y'],
+ 0,
+ 0,
+ $box ['width'],
+ $box ['height'],
+ $sourceWidth,
+ $sourceHeight
+ );
seometataginfo_destroy_image_resource($source);
if (!$copied) {
seometataginfo_destroy_image_resource($canvas);
@@ -1164,7 +1359,16 @@ function seometataginfo_render_og_image($imageInfo, $targetWidth, $targetHeight)
function seometataginfo_serve_og_image() {
$config = seometataginfo_get_runtime_config();
$baseUrl = isset($config ['general'] ['www']) ? $config ['general'] ['www'] : '';
- $imageInfo = seometataginfo_get_og_image_source_info($baseUrl);
+
+ $requestedContent = seometataginfo_get_requested_content_og_image_info($baseUrl);
+ if (!empty($requestedContent ['requested'])) {
+ $imageInfo = isset($requestedContent ['image_info']) && is_array($requestedContent ['image_info'])
+ ? $requestedContent ['image_info']
+ : array();
+ } else {
+ $imageInfo = seometataginfo_get_og_image_source_info($baseUrl);
+ }
+
if (empty($imageInfo ['absolute_path']) || empty($imageInfo ['mime'])) {
seometataginfo_send_status(404);
return;
diff --git a/fp-plugins/seometataginfo/regression-test/compare_readmore_behavior.ph_ b/fp-plugins/seometataginfo/regression-test/compare_readmore_behavior.ph_
new file mode 100644
index 00000000..878a11e3
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/compare_readmore_behavior.ph_
@@ -0,0 +1,161 @@
+ array(
+ 'readmore' => array(
+ 'readmore' => 'Read more'
+ )
+ )
+ );
+}
+function get_comments_link($id) {
+ return 'https://example.test/?entry=' . rawurlencode((string) $id);
+}
+class RegressionReadMoreQuery {
+ var $single = false;
+ function __construct($single) {
+ $this->single = (bool) $single;
+ }
+ function getLastEntry() {
+ return array('entry-id', array());
+ }
+}
+class RegressionReadMoreDB {
+ var $query;
+ function __construct($query) {
+ $this->query = $query;
+ }
+ function &getQuery() {
+ return $this->query;
+ }
+}
+
+$single = ($context === 'single');
+$query = new RegressionReadMoreQuery($single);
+$fpdb = new RegressionReadMoreDB($query);
+$GLOBALS ['fpdb'] = $fpdb;
+$GLOBALS ['fp_params'] = $single ? array('entry' => 'entry-id') : array();
+$_GET = $context === 'stream_page_param' ? array('page' => 'static-id') : array();
+
+require $plugin;
+echo base64_encode(plugin_readmore_main($input));
+PHP;
+
+file_put_contents($childFile, $childCode);
+
+$cases = array(
+ 'plain_short' => 'abc',
+ 'plain_long' => 'abcdef',
+ 'manual_marker' => 'AA[more]BB',
+ 'sentences' => 'One. Two. Three. Four. Five. Six.',
+ 'image_like_html' => 'Intro
[more] tail'
+);
+$modes = array('manual', 'auto', 'semiauto', 'sentence', 'invalid-mode');
+$contexts = array('stream', 'single', 'stream_page_param');
+
+$results = array();
+$failed = 0;
+
+foreach ($modes as $mode) {
+ foreach ($contexts as $context) {
+ foreach ($cases as $name => $input) {
+ $outputs = array();
+ foreach (array('original' => $originalPlugin, 'patched' => $patchedPlugin) as $side => $plugin) {
+ $command = escapeshellarg(PHP_BINARY)
+ . ' '
+ . escapeshellarg($childFile)
+ . ' '
+ . escapeshellarg($plugin)
+ . ' '
+ . escapeshellarg($mode)
+ . ' '
+ . escapeshellarg($context)
+ . ' '
+ . escapeshellarg(base64_encode($input));
+
+ $lines = array();
+ $status = 0;
+ exec($command, $lines, $status);
+ $outputs [$side] = array(
+ 'status' => $status,
+ 'output' => $status === 0 ? base64_decode(implode("\n", $lines)) : ''
+ );
+ }
+
+ $equal = $outputs ['original'] ['status'] === 0
+ && $outputs ['patched'] ['status'] === 0
+ && $outputs ['original'] ['output'] === $outputs ['patched'] ['output'];
+
+ if (!$equal) {
+ $failed++;
+ }
+
+ $results [] = array(
+ 'mode' => $mode,
+ 'context' => $context,
+ 'case' => $name,
+ 'status' => $equal ? 'PASS' : 'FAIL'
+ );
+ }
+ }
+}
+
+@unlink($childFile);
+
+$summary = array(
+ 'php_version' => PHP_VERSION,
+ 'total' => count($results),
+ 'passed' => count($results) - $failed,
+ 'failed' => $failed,
+ 'results' => $results
+);
+
+echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+exit($failed === 0 ? 0 : 1);
+?>
diff --git a/fp-plugins/seometataginfo/regression-test/og-image-format-results.json b/fp-plugins/seometataginfo/regression-test/og-image-format-results.json
new file mode 100644
index 00000000..6b04b9d2
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/og-image-format-results.json
@@ -0,0 +1,113 @@
+{
+ "php_version": "8.4.23",
+ "total": 21,
+ "passed": 21,
+ "failed": 0,
+ "results": [
+ {
+ "name": "html_escaped_query_fixture_is_self_contained",
+ "status": "PASS",
+ "details": "/tmp/flatpress-seometa-og-format-2004/fp-content/images/repo-independent-og-source.png"
+ },
+ {
+ "name": "local_source_metadata_is_transformable_png",
+ "status": "PASS",
+ "details": "{\"url\":\"https:\\/\\/example.test\\/flatpress\\/fp-content\\/images\\/format-source.png\",\"secure_url\":\"https:\\/\\/example.test\\/flatpress\\/fp-content\\/images\\/format-source.png\",\"mime\":\"image\\/png\",\"width\":1,\"height\":1,\"alt\":\"\",\"relative_path\":\"fp-content\\/images\\/format-source.png\",\"absolute_path\":\"\\/tmp\\/flatpress-seometa-og-format-2004\\/fp-content\\/images\\/format-source.png\",\"type\":3,\"mtime\":1786803499,\"size_bytes\":68}"
+ },
+ {
+ "name": "content_image_is_published_via_dynamic_og_endpoint",
+ "status": "PASS",
+ "details": "https://example.test/flatpress/index.php?seometa_ogimage=1&v=1786803499&seometa_ogsource=fp-content%2Fimages%2Fformat-source.png"
+ },
+ {
+ "name": "dynamic_content_meta_advertises_1200x630",
+ "status": "PASS",
+ "details": "width=1200; height=630"
+ },
+ {
+ "name": "dynamic_content_meta_preserves_image_alt_text",
+ "status": "PASS",
+ "details": "Prepared image title"
+ },
+ {
+ "name": "og_image_alt_prefers_selected_image_title",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "og_image_alt_falls_back_to_site_title",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "og_image_alt_uses_preview_only_when_site_title_is_empty",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "dynamic_endpoint_rehydrates_validated_content_source",
+ "status": "PASS",
+ "details": "fp-content/images/format-source.png"
+ },
+ {
+ "name": "literal_html_escaped_query_rehydrates_reported_content_source",
+ "status": "PASS",
+ "details": "{\"get\":{\"seometa_ogimage\":\"1\",\"amp;v\":\"1786722663\",\"amp;seometa_ogsource\":\"fp-content\\/images\\/repo-independent-og-source.png\"},\"request\":{\"requested\":true,\"image_info\":{\"url\":\"https:\\/\\/example.test\\/flatpress\\/fp-content\\/images\\/repo-independent-og-source.png\",\"secure_url\":\"https:\\/\\/example.test\\/flatpress\\/fp-content\\/images\\/repo-independent-og-source.png\",\"mime\":\"image\\/png\",\"width\":1,\"height\":1,\"alt\":\"\",\"relative_path\":\"fp-content\\/images\\/repo-independent-og-source.png\",\"absolute_path\":\"\\/tmp\\/flatpress-seometa-og-format-2004\\/fp-content\\/images\\/repo-independent-og-source.png\",\"type\":3,\"mtime\":1786803499,\"size_bytes\":68}}}"
+ },
+ {
+ "name": "double_html_escaped_query_rehydrates_content_source",
+ "status": "PASS",
+ "details": "{\"get\":{\"seometa_ogimage\":\"1\",\"amp;amp;v\":\"1786722663\",\"amp;amp;seometa_ogsource\":\"fp-content\\/images\\/repo-independent-og-source.png\"},\"request\":{\"requested\":true,\"image_info\":{\"url\":\"https:\\/\\/example.test\\/flatpress\\/fp-content\\/images\\/repo-independent-og-source.png\",\"secure_url\":\"https:\\/\\/example.test\\/flatpress\\/fp-content\\/images\\/repo-independent-og-source.png\",\"mime\":\"image\\/png\",\"width\":1,\"height\":1,\"alt\":\"\",\"relative_path\":\"fp-content\\/images\\/repo-independent-og-source.png\",\"absolute_path\":\"\\/tmp\\/flatpress-seometa-og-format-2004\\/fp-content\\/images\\/repo-independent-og-source.png\",\"type\":3,\"mtime\":1786803499,\"size_bytes\":68}}}"
+ },
+ {
+ "name": "literal_html_escaped_og_flag_is_recognized",
+ "status": "PASS",
+ "details": "{\"foo\":\"bar\",\"amp;seometa_ogimage\":\"1\",\"amp;seometa_ogsource\":\"fp-content\\/images\\/repo-independent-og-source.png\"}"
+ },
+ {
+ "name": "exact_query_parameter_precedes_escaped_alias",
+ "status": "PASS",
+ "details": "{\"seometa_ogimage\":\"1\",\"seometa_ogsource\":\"fp-content\\/images\\/repo-independent-og-source.png\",\"amp;seometa_ogsource\":\"fp-content\\/images\\/..\\/config\\/invalid.png\"}"
+ },
+ {
+ "name": "escaped_alias_with_blog_root_path_is_normalized",
+ "status": "PASS",
+ "details": "{\"seometa_ogimage\":\"1\",\"amp;seometa_ogsource\":\"\\/flatpress\\/fp-content\\/images\\/repo-independent-og-source.png\"}"
+ },
+ {
+ "name": "escaped_alias_traversal_is_rejected_without_theme_fallback",
+ "status": "PASS",
+ "details": "{\"requested\":true,\"image_info\":[]}"
+ },
+ {
+ "name": "dynamic_endpoint_rejects_path_traversal",
+ "status": "PASS",
+ "details": "{\"requested\":true,\"image_info\":[]}"
+ },
+ {
+ "name": "contain_geometry_landscape_16_9",
+ "status": "PASS",
+ "details": "{\"x\":40,\"y\":0,\"width\":1120,\"height\":630}; ratio_error=0"
+ },
+ {
+ "name": "contain_geometry_portrait_9_16",
+ "status": "PASS",
+ "details": "{\"x\":423,\"y\":0,\"width\":354,\"height\":630}; ratio_error=0.00059523809523809"
+ },
+ {
+ "name": "contain_geometry_square",
+ "status": "PASS",
+ "details": "{\"x\":285,\"y\":0,\"width\":630,\"height\":630}; ratio_error=0"
+ },
+ {
+ "name": "contain_geometry_already_1200x630",
+ "status": "PASS",
+ "details": "{\"x\":0,\"y\":0,\"width\":1200,\"height\":630}; ratio_error=0"
+ },
+ {
+ "name": "remote_image_remains_direct_without_server_side_fetch",
+ "status": "PASS",
+ "details": "{\"url\":\"https:\\/\\/cdn.example.test\\/original.jpg\",\"secure_url\":\"https:\\/\\/cdn.example.test\\/original.jpg\",\"mime\":\"\",\"width\":0,\"height\":0,\"alt\":\"\"}"
+ }
+ ]
+}
diff --git a/fp-plugins/seometataginfo/regression-test/simulate_og_content_image.ph_ b/fp-plugins/seometataginfo/regression-test/simulate_og_content_image.ph_
new file mode 100644
index 00000000..707b282d
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/simulate_og_content_image.ph_
@@ -0,0 +1,515 @@
+ array(
+ 'readmore' => array(
+ 'readmore' => 'Read more'
+ )
+ )
+ );
+}
+
+function get_comments_link($id) {
+ return BLOG_BASEURL . '?entry=' . rawurlencode((string)$id);
+}
+
+function is_apcu_on() {
+ return false;
+}
+
+require_once $flatpressRoot . 'fp-plugins/bbcode/inc/stringparser_bbcode.class.php';
+require_once $flatpressRoot . 'fp-includes/core/core.filesystem.php';
+require_once $flatpressRoot . 'fp-includes/core/core.fileio.php';
+require_once $flatpressRoot . 'fp-includes/core/core.gallery.php';
+
+function regression_dummy_media($action, $attributes, $content, $params, $node_object) {
+ if ($action === 'validate') {
+ return true;
+ }
+ return '
';
+}
+
+function regression_dummy_code($action, $attributes, $content, $params, $node_object) {
+ if ($action === 'validate') {
+ return true;
+ }
+ return '' . $content . '';
+}
+
+$GLOBALS ['regression_bbcode_parser'] = new StringParser_BBCode();
+$GLOBALS ['regression_bbcode_parser']->setGlobalCaseSensitive(false);
+$GLOBALS ['regression_bbcode_parser']->setMixedAttributeTypes(true);
+
+$GLOBALS ['regression_bbcode_parser']->addCode(
+ 'img',
+ 'callback_replace_single',
+ 'regression_dummy_media',
+ array('usecontent_param' => array('default', 'float', 'alt', 'popup', 'width', 'height', 'title')),
+ 'image',
+ array('listitem', 'block', 'inline', 'link'),
+ array()
+);
+$GLOBALS ['regression_bbcode_parser']->setCodeFlag('img', 'closetag', BBCODE_CLOSETAG_FORBIDDEN);
+
+foreach (array('gallery', 'photoswipegallery', 'photoswipeimage') as $tag) {
+ $GLOBALS ['regression_bbcode_parser']->addCode(
+ $tag,
+ 'callback_replace_single',
+ 'regression_dummy_media',
+ array('usecontent_param' => array('default')),
+ 'inline',
+ array('listitem', 'block', 'inline', 'link'),
+ array()
+ );
+ $GLOBALS ['regression_bbcode_parser']->setCodeFlag($tag, 'closetag', BBCODE_CLOSETAG_FORBIDDEN);
+}
+
+$GLOBALS ['regression_bbcode_parser']->addCode(
+ 'code',
+ 'usecontent',
+ 'regression_dummy_code',
+ array(),
+ 'inline',
+ array('listitem', 'block', 'inline', 'link'),
+ array()
+);
+$GLOBALS ['regression_bbcode_parser']->setCodeFlag('code', 'closetag', BBCODE_CLOSETAG_MUSTEXIST);
+
+function &plugin_bbcode_init() {
+ return $GLOBALS ['regression_bbcode_parser'];
+}
+
+require_once $flatpressRoot . 'fp-plugins/readmore/plugin.readmore.php';
+require_once $flatpressRoot . 'fp-plugins/seometataginfo/inc/og-content-image.php';
+
+class RegressionQueryParams {
+ public $start = 0;
+ public $count = 0;
+ public $category = 0;
+ public $exclude = null;
+ public $y = null;
+ public $m = null;
+ public $d = null;
+}
+
+class FPDB_Query {
+ public $params;
+ public $single = false;
+ private $ids = array();
+ private $pointer = 0;
+
+ public function __construct($params, $id) {
+ unset($id);
+ $this->params = new RegressionQueryParams();
+ $this->params->start = isset($params ['start']) ? (int)$params ['start'] : 0;
+ $this->params->count = isset($params ['count']) ? (int)$params ['count'] : count($GLOBALS ['regression_stream_ids']);
+ foreach (array('category', 'exclude', 'y', 'm', 'd') as $key) {
+ if (array_key_exists($key, $params)) {
+ $this->params->{$key} = $params [$key];
+ }
+ }
+
+ $all = isset($GLOBALS ['regression_stream_ids']) && is_array($GLOBALS ['regression_stream_ids'])
+ ? $GLOBALS ['regression_stream_ids']
+ : array();
+ $this->ids = array_slice($all, $this->params->start, $this->params->count);
+ $GLOBALS ['current_query'] = $this;
+ }
+
+ public function hasMore() {
+ $GLOBALS ['current_query'] = $this;
+ return $this->pointer < count($this->ids);
+ }
+
+ public function &getEntry() {
+ $id = $this->ids [$this->pointer++];
+ $entry = array('subject' => 'Entry ' . $id);
+ $result = array($id, $entry);
+ return $result;
+ }
+
+ public function &peekEntry() {
+ if (!$this->hasMore()) {
+ $empty = array(false, false);
+ return $empty;
+ }
+ $id = $this->ids [$this->pointer];
+ $entry = array('subject' => 'Entry ' . $id);
+ $result = array($id, $entry);
+ return $result;
+ }
+}
+
+class RegressionFPDB {
+ public $query;
+
+ public function __construct($query) {
+ $this->query = $query;
+ }
+
+ public function &getQuery() {
+ return $this->query;
+ }
+}
+
+function entry_parse($id) {
+ return isset($GLOBALS ['regression_entries'] [$id])
+ ? $GLOBALS ['regression_entries'] [$id]
+ : false;
+}
+
+function regression_mkdir($path) {
+ if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
+ throw new RuntimeException('Cannot create directory: ' . $path);
+ }
+}
+
+function regression_write_png($relative) {
+ // Valid 1x1 PNG.
+ $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=');
+ $path = ABS_PATH . $relative;
+ regression_mkdir(dirname($path));
+ file_put_contents($path, $png);
+ return $path;
+}
+
+function regression_remove_tree($dir) {
+ if (!file_exists($dir)) {
+ return;
+ }
+ if (!is_dir($dir)) {
+ @unlink($dir);
+ return;
+ }
+ $items = scandir($dir);
+ if (is_array($items)) {
+ foreach ($items as $item) {
+ if ($item === '.' || $item === '..') {
+ continue;
+ }
+ regression_remove_tree($dir . '/' . $item);
+ }
+ }
+ @rmdir($dir);
+}
+
+$results = array();
+$failures = 0;
+
+function regression_assert($name, $condition, $details = '') {
+ global $results, $failures;
+ $ok = (bool)$condition;
+ $results [] = array(
+ 'name' => $name,
+ 'status' => $ok ? 'PASS' : 'FAIL',
+ 'details' => (string)$details
+ );
+ if (!$ok) {
+ $failures++;
+ }
+}
+
+function regression_expect_url($name, $meta, $expected) {
+ $actual = is_array($meta) && isset($meta ['url']) ? (string)$meta ['url'] : '';
+ regression_assert($name, $actual === $expected, 'expected=' . $expected . '; actual=' . $actual);
+}
+
+function regression_expect_alt($name, $meta, $expected) {
+ $actual = is_array($meta) && isset($meta ['alt']) ? (string)$meta ['alt'] : '';
+ regression_assert($name, $actual === $expected, 'expected=' . $expected . '; actual=' . $actual);
+}
+
+try {
+ regression_mkdir(ABS_PATH . IMAGES_DIR);
+
+ // Single images and thumbnails.
+ foreach (array('single.png', 'stream.png', 'second.png', 'after-more.png', 'before-more.png') as $file) {
+ regression_write_png(IMAGES_DIR . $file);
+ }
+ regression_write_png(IMAGES_DIR . '.thumbs/single.png');
+ regression_write_png(IMAGES_DIR . '.thumbs/stream.png');
+
+ // Main gallery, sorted a.png before b.png; thumbnail directory must not participate.
+ regression_write_png(IMAGES_DIR . 'gallery-main/a.png');
+ regression_write_png(IMAGES_DIR . 'gallery-main/b.png');
+ regression_write_png(IMAGES_DIR . 'gallery-main/.thumbs/a.png');
+ file_put_contents(ABS_PATH . IMAGES_DIR . 'gallery-main/.captions.conf', "a.png=First\n");
+
+ // Empty gallery.
+ regression_mkdir(ABS_PATH . IMAGES_DIR . 'gallery-empty');
+
+ // Gallery where first sorted file is not an image. Its caption must not leak
+ // to the valid second image selected by the SEO resolver.
+ regression_mkdir(ABS_PATH . IMAGES_DIR . 'gallery-invalid-first');
+ file_put_contents(ABS_PATH . IMAGES_DIR . 'gallery-invalid-first/a.txt', 'not an image');
+ regression_write_png(IMAGES_DIR . 'gallery-invalid-first/b.png');
+ file_put_contents(
+ ABS_PATH . IMAGES_DIR . 'gallery-invalid-first/.captions.conf',
+ "a.txt=Wrong caption\nb.png=Selected & caption\n"
+ );
+
+ // First valid image has no caption while a later valid image does. The later
+ // caption must never change the selected OG image.
+ regression_write_png(IMAGES_DIR . 'gallery-later-caption/a.png');
+ regression_write_png(IMAGES_DIR . 'gallery-later-caption/b.png');
+ file_put_contents(
+ ABS_PATH . IMAGES_DIR . 'gallery-later-caption/.captions.conf',
+ "b.png=Later caption\n"
+ );
+
+ // gallery_read_images() resolves IMAGES_DIR relative to the FlatPress working directory.
+ // Mirror the real index.php runtime after the temporary fixture exists.
+ $previousWorkingDirectory = getcwd();
+ if (!chdir(ABS_PATH)) {
+ throw new RuntimeException('Cannot change simulation working directory to ABS_PATH.');
+ }
+
+ $base = BLOG_BASEURL;
+
+ // Core six requirements: the content resolver is identical for single/static;
+ // stream cases are exercised through the independent FPDB scan below.
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png" width="40"]', $base, false);
+ regression_expect_url('3_single_entry_first_image_original', $meta, $base . IMAGES_DIR . 'single.png');
+ regression_assert('3_single_entry_not_thumbnail', strpos($meta ['url'], '/.thumbs/') === false, $meta ['url']);
+ regression_assert(
+ 'local_image_keeps_validated_transform_source',
+ isset($meta ['relative_path'], $meta ['absolute_path'], $meta ['type'])
+ && $meta ['relative_path'] === IMAGES_DIR . 'single.png'
+ && is_file($meta ['absolute_path'])
+ && (int)$meta ['type'] === 3,
+ json_encode(array(
+ 'relative_path' => isset($meta ['relative_path']) ? $meta ['relative_path'] : '',
+ 'type' => isset($meta ['type']) ? $meta ['type'] : 0
+ ))
+ );
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png" title="Single image title"]', $base, false);
+ regression_expect_alt('single_image_explicit_title_becomes_og_alt', $meta, 'Single image title');
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png"]', $base, false);
+ regression_expect_alt('single_image_without_title_keeps_alt_empty_for_site_fallback', $meta, '');
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png" alt="Ignored alternative"]', $base, false);
+ regression_expect_alt('single_image_alt_attribute_does_not_replace_missing_title', $meta, '');
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png" title="A & B"]', $base, false);
+ regression_expect_alt('single_image_title_entities_are_normalized_without_double_escape', $meta, 'A & B');
+
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-main"]', $base, false);
+ regression_expect_url('4_single_entry_first_gallery_image_original', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+ regression_expect_alt('gallery_selected_image_uses_gallery_caption', $meta, 'First');
+
+ $meta = seometataginfo_find_first_content_image_meta('Static [img="images/single.png" popup="false"]', $base, false);
+ regression_expect_url('5_static_page_first_image_original', $meta, $base . IMAGES_DIR . 'single.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('Static [gallery="images/gallery-main"]', $base, false);
+ regression_expect_url('6_static_page_first_gallery_image_original', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ // Source order.
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-main"] [img="images/single.png"]', $base, false);
+ regression_expect_url('source_order_gallery_before_image', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png"] [gallery="images/gallery-main"]', $base, false);
+ regression_expect_url('source_order_image_before_gallery', $meta, $base . IMAGES_DIR . 'single.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png"] [img="images/second.png"]', $base, false);
+ regression_expect_url('multiple_images_first_original_wins', $meta, $base . IMAGES_DIR . 'single.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-empty"] [gallery="images/gallery-main"]', $base, false);
+ regression_expect_url('empty_first_gallery_then_second_gallery', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[photoswipeimage="images/single.png"]', $base, false);
+ regression_expect_url('photoswipeimage_original', $meta, $base . IMAGES_DIR . 'single.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[photoswipegallery="images/gallery-main"]', $base, false);
+ regression_expect_url('photoswipegallery_first_original', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ // Invalid media falls through to the next visible valid media.
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/missing.png"] [gallery="images/gallery-main"]', $base, false);
+ regression_expect_url('invalid_image_then_valid_gallery', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-empty"] [img="images/single.png"]', $base, false);
+ regression_expect_url('empty_gallery_then_valid_image', $meta, $base . IMAGES_DIR . 'single.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-invalid-first"]', $base, false);
+ regression_expect_url('gallery_skips_non_image_first_file', $meta, $base . IMAGES_DIR . 'gallery-invalid-first/b.png');
+ regression_expect_alt('gallery_caption_is_bound_to_selected_valid_file', $meta, 'Selected & caption');
+
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-later-caption"]', $base, false);
+ regression_expect_url('gallery_missing_first_caption_does_not_select_later_image', $meta, $base . IMAGES_DIR . 'gallery-later-caption/a.png');
+ regression_expect_alt('gallery_missing_first_caption_keeps_alt_empty_for_site_fallback', $meta, '');
+
+ // Thumbnail/popup/external handling.
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/stream.png" width="1" height="1"]', $base, false);
+ regression_expect_url('thumbnail_present_original_still_selected', $meta, $base . IMAGES_DIR . 'stream.png');
+ regression_assert('thumbnail_path_never_published', strpos($meta ['url'], '/.thumbs/') === false, $meta ['url']);
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/single.png" popup="false"]', $base, false);
+ regression_expect_url('popup_false_still_original', $meta, $base . IMAGES_DIR . 'single.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('[img="https://cdn.example.test/original.jpg" width="20" title="Remote title"]', $base, false);
+ regression_expect_url('external_image_direct_url_no_download', $meta, 'https://cdn.example.test/original.jpg');
+ regression_expect_alt('external_image_explicit_title_is_preserved', $meta, 'Remote title');
+ regression_assert(
+ 'external_image_has_no_local_transform_source',
+ empty($meta ['relative_path']) && empty($meta ['absolute_path']),
+ isset($meta ['relative_path']) ? (string)$meta ['relative_path'] : ''
+ );
+
+ // Manual ReadMore integration.
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/before-more.png"] [more] [img="images/after-more.png"]', $base, true);
+ regression_expect_url('readmore_manual_image_before_more_visible', $meta, $base . IMAGES_DIR . 'before-more.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('Intro [more] [img="images/after-more.png"]', $base, true);
+ regression_assert('readmore_manual_image_after_more_hidden', empty($meta ['url']), isset($meta ['url']) ? $meta ['url'] : '');
+
+ $meta = seometataginfo_find_first_content_image_meta('Intro [more] [gallery="images/gallery-main"]', $base, true);
+ regression_assert('readmore_manual_gallery_after_more_hidden', empty($meta ['url']), isset($meta ['url']) ? $meta ['url'] : '');
+
+ $meta = seometataginfo_find_first_content_image_meta('[gallery="images/gallery-main"] [more] Tail', $base, true);
+ regression_expect_url('readmore_manual_gallery_before_more_visible', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('Intro [more] [img="images/after-more.png"]', $base, false);
+ regression_expect_url('readmore_inactive_full_stream_content', $meta, $base . IMAGES_DIR . 'after-more.png');
+
+ // Single/static views deliberately ignore [more].
+ $meta = seometataginfo_find_first_content_image_meta('Intro [more] [img="images/after-more.png"]', $base, false);
+ regression_expect_url('single_entry_image_after_more_allowed', $meta, $base . IMAGES_DIR . 'after-more.png');
+
+ $meta = seometataginfo_find_first_content_image_meta('Static [more] [gallery="images/gallery-main"]', $base, false);
+ regression_expect_url('static_page_gallery_after_more_allowed', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ // Probe + mode semantics for non-default ReadMore modes.
+ $probe = seometataginfo_content_probe_media('[img="images/single.png"] text');
+ $marker = isset($probe ['tokens'] [0] ['marker']) ? $probe ['tokens'] [0] ['marker'] : '';
+ $excerpt = plugin_readmore_get_stream_excerpt($probe ['html'], 'auto', 4);
+ regression_assert('readmore_auto_four_chars_hides_media_marker', $marker !== '' && strpos($excerpt ['content'], $marker) === false);
+
+ $excerpt = plugin_readmore_get_stream_excerpt($probe ['html'], 'semiauto', 4);
+ regression_assert('readmore_semiauto_four_chars_hides_media_marker', $marker !== '' && strpos($excerpt ['content'], $marker) === false);
+
+ $probe = seometataginfo_content_probe_media('One. [img="images/single.png"] Two. Three. Four. Five. ');
+ $marker = isset($probe ['tokens'] [0] ['marker']) ? $probe ['tokens'] [0] ['marker'] : '';
+ $excerpt = plugin_readmore_get_stream_excerpt($probe ['html'], 'sentence', 4);
+ regression_assert('readmore_sentence_media_before_fourth_sentence_visible', $marker !== '' && strpos($excerpt ['content'], $marker) !== false);
+
+ $probe = seometataginfo_content_probe_media('One. Two. Three. Four. [img="images/single.png"] Five. ');
+ $marker = isset($probe ['tokens'] [0] ['marker']) ? $probe ['tokens'] [0] ['marker'] : '';
+ $excerpt = plugin_readmore_get_stream_excerpt($probe ['html'], 'sentence', 4);
+ regression_assert('readmore_sentence_media_after_fourth_sentence_hidden', $marker !== '' && strpos($excerpt ['content'], $marker) === false);
+
+ // Escaped/literal code must not become a media occurrence.
+ $probe = seometataginfo_content_probe_media('[code][img="images/single.png"][/code]');
+ regression_assert('bbcode_code_literal_not_media', empty($probe ['tokens']), 'tokens=' . count($probe ['tokens']));
+
+ // Stream query: first entry without media, second with image.
+ $GLOBALS ['regression_stream_ids'] = array('e1', 'e2');
+ $GLOBALS ['regression_entries'] = array(
+ 'e1' => array('content' => 'No media here.'),
+ 'e2' => array('content' => '[img="images/stream.png" width="10"]')
+ );
+ $active = new FPDB_Query(array('start' => 0, 'count' => 2), 0);
+ $GLOBALS ['fpdb'] = new RegressionFPDB($active);
+ $GLOBALS ['current_query'] = $active;
+ $savedCurrent = $GLOBALS ['current_query'];
+
+ $meta = seometataginfo_get_stream_content_image_meta($base);
+ regression_expect_url('1_stream_multiple_entries_first_visible_image_original', $meta, $base . IMAGES_DIR . 'stream.png');
+ regression_assert('stream_scan_restores_current_query', $GLOBALS ['current_query'] === $savedCurrent);
+
+ // Stream query: gallery in second entry.
+ $GLOBALS ['regression_entries'] = array(
+ 'e1' => array('content' => 'No media here.'),
+ 'e2' => array('content' => '[gallery="images/gallery-main"]')
+ );
+ $meta = seometataginfo_get_stream_content_image_meta($base);
+ regression_expect_url('2_stream_multiple_entries_first_gallery_image_original', $meta, $base . IMAGES_DIR . 'gallery-main/a.png');
+
+ // Stream query: media hidden behind [more] in entry 1, visible image in entry 2.
+ $GLOBALS ['regression_entries'] = array(
+ 'e1' => array('content' => 'Intro [more] [img="images/after-more.png" title="Hidden title"]'),
+ 'e2' => array('content' => '[img="images/second.png" title="Visible second title"]')
+ );
+ $meta = seometataginfo_get_stream_content_image_meta($base);
+ regression_expect_url('stream_hidden_first_entry_media_falls_to_second_entry', $meta, $base . IMAGES_DIR . 'second.png');
+ regression_expect_alt('stream_hidden_title_does_not_override_visible_second_title', $meta, 'Visible second title');
+
+ // PhotoSwipe-specific tags are recognized only while they are registered
+ // in the active BBCode parser. Removing them simulates PhotoSwipe disabled.
+ $savedParser = clone $GLOBALS ['regression_bbcode_parser'];
+ foreach (array('gallery', 'photoswipegallery', 'photoswipeimage') as $tag) {
+ $GLOBALS ['regression_bbcode_parser']->removeCode($tag);
+ }
+ $probe = seometataginfo_content_probe_media('[gallery="images/gallery-main"] [photoswipeimage="images/single.png"]');
+ regression_assert('photoswipe_disabled_tags_not_recognized', empty($probe ['tokens']), 'tokens=' . count($probe ['tokens']));
+ $GLOBALS ['regression_bbcode_parser'] = $savedParser;
+
+ // Local path traversal and non-image input must not escape images/.
+ $meta = seometataginfo_find_first_content_image_meta('[img="images/../outside.png"]', $base, false);
+ regression_assert('path_traversal_rejected', empty($meta ['url']), isset($meta ['url']) ? $meta ['url'] : '');
+
+ // Gallery ordering must be the exact gallery_read_images() ordering.
+ $list = gallery_read_images('images/gallery-main/');
+ regression_assert('gallery_read_images_order_is_used', is_array($list) && isset($list [0]) && $list [0] === 'a.png', json_encode($list));
+
+} catch (Throwable $e) {
+ $failures++;
+ $results [] = array(
+ 'name' => 'simulation_exception',
+ 'status' => 'FAIL',
+ 'details' => get_class($e) . ': ' . $e->getMessage()
+ );
+}
+
+if (isset($previousWorkingDirectory) && is_string($previousWorkingDirectory) && $previousWorkingDirectory !== '') {
+ @chdir($previousWorkingDirectory);
+}
+regression_remove_tree($fixtureRoot);
+
+$summary = array(
+ 'php_version' => PHP_VERSION,
+ 'total' => count($results),
+ 'passed' => count($results) - $failures,
+ 'failed' => $failures,
+ 'results' => $results
+);
+
+echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+exit($failures === 0 ? 0 : 1);
+?>
diff --git a/fp-plugins/seometataginfo/regression-test/simulation-results.json b/fp-plugins/seometataginfo/regression-test/simulation-results.json
new file mode 100644
index 00000000..790bc335
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/simulation-results.json
@@ -0,0 +1,253 @@
+{
+ "php_version": "8.4.23",
+ "total": 49,
+ "passed": 49,
+ "failed": 0,
+ "results": [
+ {
+ "name": "3_single_entry_first_image_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "3_single_entry_not_thumbnail",
+ "status": "PASS",
+ "details": "https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "local_image_keeps_validated_transform_source",
+ "status": "PASS",
+ "details": "{\"relative_path\":\"fp-content\\/images\\/single.png\",\"type\":3}"
+ },
+ {
+ "name": "single_image_explicit_title_becomes_og_alt",
+ "status": "PASS",
+ "details": "expected=Single image title; actual=Single image title"
+ },
+ {
+ "name": "single_image_without_title_keeps_alt_empty_for_site_fallback",
+ "status": "PASS",
+ "details": "expected=; actual="
+ },
+ {
+ "name": "single_image_alt_attribute_does_not_replace_missing_title",
+ "status": "PASS",
+ "details": "expected=; actual="
+ },
+ {
+ "name": "single_image_title_entities_are_normalized_without_double_escape",
+ "status": "PASS",
+ "details": "expected=A & B; actual=A & B"
+ },
+ {
+ "name": "4_single_entry_first_gallery_image_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "gallery_selected_image_uses_gallery_caption",
+ "status": "PASS",
+ "details": "expected=First; actual=First"
+ },
+ {
+ "name": "5_static_page_first_image_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "6_static_page_first_gallery_image_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "source_order_gallery_before_image",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "source_order_image_before_gallery",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "multiple_images_first_original_wins",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "empty_first_gallery_then_second_gallery",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "photoswipeimage_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "photoswipegallery_first_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "invalid_image_then_valid_gallery",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "empty_gallery_then_valid_image",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "gallery_skips_non_image_first_file",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-invalid-first/b.png; actual=https://example.test/flatpress/fp-content/images/gallery-invalid-first/b.png"
+ },
+ {
+ "name": "gallery_caption_is_bound_to_selected_valid_file",
+ "status": "PASS",
+ "details": "expected=Selected & caption; actual=Selected & caption"
+ },
+ {
+ "name": "gallery_missing_first_caption_does_not_select_later_image",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-later-caption/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-later-caption/a.png"
+ },
+ {
+ "name": "gallery_missing_first_caption_keeps_alt_empty_for_site_fallback",
+ "status": "PASS",
+ "details": "expected=; actual="
+ },
+ {
+ "name": "thumbnail_present_original_still_selected",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/stream.png; actual=https://example.test/flatpress/fp-content/images/stream.png"
+ },
+ {
+ "name": "thumbnail_path_never_published",
+ "status": "PASS",
+ "details": "https://example.test/flatpress/fp-content/images/stream.png"
+ },
+ {
+ "name": "popup_false_still_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/single.png; actual=https://example.test/flatpress/fp-content/images/single.png"
+ },
+ {
+ "name": "external_image_direct_url_no_download",
+ "status": "PASS",
+ "details": "expected=https://cdn.example.test/original.jpg; actual=https://cdn.example.test/original.jpg"
+ },
+ {
+ "name": "external_image_explicit_title_is_preserved",
+ "status": "PASS",
+ "details": "expected=Remote title; actual=Remote title"
+ },
+ {
+ "name": "external_image_has_no_local_transform_source",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "readmore_manual_image_before_more_visible",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/before-more.png; actual=https://example.test/flatpress/fp-content/images/before-more.png"
+ },
+ {
+ "name": "readmore_manual_image_after_more_hidden",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "readmore_manual_gallery_after_more_hidden",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "readmore_manual_gallery_before_more_visible",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "readmore_inactive_full_stream_content",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/after-more.png; actual=https://example.test/flatpress/fp-content/images/after-more.png"
+ },
+ {
+ "name": "single_entry_image_after_more_allowed",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/after-more.png; actual=https://example.test/flatpress/fp-content/images/after-more.png"
+ },
+ {
+ "name": "static_page_gallery_after_more_allowed",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "readmore_auto_four_chars_hides_media_marker",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "readmore_semiauto_four_chars_hides_media_marker",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "readmore_sentence_media_before_fourth_sentence_visible",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "readmore_sentence_media_after_fourth_sentence_hidden",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "bbcode_code_literal_not_media",
+ "status": "PASS",
+ "details": "tokens=0"
+ },
+ {
+ "name": "1_stream_multiple_entries_first_visible_image_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/stream.png; actual=https://example.test/flatpress/fp-content/images/stream.png"
+ },
+ {
+ "name": "stream_scan_restores_current_query",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "2_stream_multiple_entries_first_gallery_image_original",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/gallery-main/a.png; actual=https://example.test/flatpress/fp-content/images/gallery-main/a.png"
+ },
+ {
+ "name": "stream_hidden_first_entry_media_falls_to_second_entry",
+ "status": "PASS",
+ "details": "expected=https://example.test/flatpress/fp-content/images/second.png; actual=https://example.test/flatpress/fp-content/images/second.png"
+ },
+ {
+ "name": "stream_hidden_title_does_not_override_visible_second_title",
+ "status": "PASS",
+ "details": "expected=Visible second title; actual=Visible second title"
+ },
+ {
+ "name": "photoswipe_disabled_tags_not_recognized",
+ "status": "PASS",
+ "details": "tokens=0"
+ },
+ {
+ "name": "path_traversal_rejected",
+ "status": "PASS",
+ "details": ""
+ },
+ {
+ "name": "gallery_read_images_order_is_used",
+ "status": "PASS",
+ "details": "[\"a.png\",\"b.png\"]"
+ }
+ ]
+}
diff --git a/fp-plugins/seometataginfo/regression-test/target-parser-results.json b/fp-plugins/seometataginfo/regression-test/target-parser-results.json
new file mode 100644
index 00000000..55e37150
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/target-parser-results.json
@@ -0,0 +1,43 @@
+{
+ "php_version": "8.4.23",
+ "total": 7,
+ "passed": 7,
+ "failed": 0,
+ "results": [
+ {
+ "name": "actual_bbcode_img_detected",
+ "status": "PASS",
+ "details": "tag=img; source=images/x.jpg"
+ },
+ {
+ "name": "actual_photoswipe_gallery_detected",
+ "status": "PASS",
+ "details": "tag=gallery; source=images/g"
+ },
+ {
+ "name": "actual_photoswipeimage_alias_detected",
+ "status": "PASS",
+ "details": "tag=photoswipeimage; source=images/x.jpg"
+ },
+ {
+ "name": "actual_photoswipegallery_alias_detected",
+ "status": "PASS",
+ "details": "tag=photoswipegallery; source=images/g"
+ },
+ {
+ "name": "actual_photoswipe_overridden_img_preserves_title_attribute",
+ "status": "PASS",
+ "details": "title=Parser title"
+ },
+ {
+ "name": "actual_bbcode_code_blocks_nested_image",
+ "status": "PASS",
+ "details": "tokens=0; html=[img=\"images/x.jpg\"]
"
+ },
+ {
+ "name": "probe_does_not_advance_photoswipe_index",
+ "status": "PASS",
+ "details": "before=0; after=0"
+ }
+ ]
+}
diff --git a/fp-plugins/seometataginfo/regression-test/validate_og_image_format.ph_ b/fp-plugins/seometataginfo/regression-test/validate_og_image_format.ph_
new file mode 100644
index 00000000..a9717d30
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/validate_og_image_format.ph_
@@ -0,0 +1,398 @@
+ array(
+ 'title' => 'Regression',
+ 'www' => BLOG_BASEURL,
+ 'startpage' => ''
+ ),
+ 'locale' => array(
+ 'charset' => 'UTF-8',
+ 'lang' => 'en-us'
+ )
+);
+
+function add_action($tag, $callback, $priority = 10, $acceptedArgs = 1) {
+ return true;
+}
+function add_filter($tag, $callback, $priority = 10, $acceptedArgs = 1) {
+ return true;
+}
+function apply_filters($tag, $value) {
+ return $value;
+}
+function lang_load($id) {
+ return array(
+ 'plugin' => array('seometataginfo' => array('sep' => ' - ')),
+ 'admin' => array('plugin' => array('seometataginfo' => array()))
+ );
+}
+function is_apcu_on() {
+ return false;
+}
+
+// GD is not installed in every CLI test image. Stub only absent functions so
+// transform-selection can be validated without pretending pixel rendering ran.
+if (!function_exists('imagecreatetruecolor')) {
+ function imagecreatetruecolor($width, $height) {
+ return (object) array('width' => $width, 'height' => $height);
+ }
+}
+if (!function_exists('imagecopyresampled')) {
+ function imagecopyresampled() {
+ return true;
+ }
+}
+if (!function_exists('imagefilledrectangle')) {
+ function imagefilledrectangle() {
+ return true;
+ }
+}
+if (!function_exists('imagecreatefrompng')) {
+ function imagecreatefrompng($path) {
+ return (object) array('path' => $path);
+ }
+}
+if (!function_exists('imagepng')) {
+ function imagepng($image, $file = null) {
+ return true;
+ }
+}
+if (!function_exists('imagecreatefromjpeg')) {
+ function imagecreatefromjpeg($path) {
+ return (object) array('path' => $path);
+ }
+}
+if (!function_exists('imagejpeg')) {
+ function imagejpeg($image, $file = null, $quality = null) {
+ return true;
+ }
+}
+
+require $root . 'fp-plugins/seometataginfo/plugin.seometataginfo.php';
+
+$results = array();
+$failed = 0;
+
+function format_assert($name, $condition, $details = '') {
+ global $results, $failed;
+ $ok = (bool) $condition;
+ $results [] = array(
+ 'name' => $name,
+ 'status' => $ok ? 'PASS' : 'FAIL',
+ 'details' => (string) $details
+ );
+ if (!$ok) {
+ $failed++;
+ }
+}
+
+function format_mkdir($dir) {
+ if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
+ throw new RuntimeException('Cannot create directory: ' . $dir);
+ }
+}
+
+function format_remove_tree($dir) {
+ if (!file_exists($dir)) {
+ return;
+ }
+ if (!is_dir($dir)) {
+ @unlink($dir);
+ return;
+ }
+ $items = scandir($dir);
+ if (is_array($items)) {
+ foreach ($items as $item) {
+ if ($item === '.' || $item === '..') {
+ continue;
+ }
+ format_remove_tree($dir . '/' . $item);
+ }
+ }
+ @rmdir($dir);
+}
+
+try {
+ format_mkdir(ABS_PATH . IMAGES_DIR);
+
+ // Valid 1x1 PNG is sufficient for getimagesize()/type metadata.
+ $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=');
+ $sourceFile = ABS_PATH . IMAGES_DIR . 'format-source.png';
+ file_put_contents($sourceFile, $png);
+ // Repository-independent fixture for HTML-entity query handling.
+ // The originally reported avm-gelaende.png belongs to one installed test
+ // instance and is intentionally not required by this regression harness.
+ $htmlEscapedFixtureName = 'repo-independent-og-source.png';
+ $htmlEscapedFixtureFile = ABS_PATH . IMAGES_DIR . $htmlEscapedFixtureName;
+ file_put_contents($htmlEscapedFixtureFile, $png);
+
+ format_assert(
+ 'html_escaped_query_fixture_is_self_contained',
+ is_file($htmlEscapedFixtureFile)
+ && strpos(str_replace('\\', '/', $htmlEscapedFixtureFile), rtrim(str_replace('\\', '/', $fixtureRoot), '/') . '/') === 0,
+ $htmlEscapedFixtureFile
+ );
+
+ $sourceMeta = seometataginfo_content_local_image_meta('images/format-source.png', BLOG_BASEURL);
+ format_assert(
+ 'local_source_metadata_is_transformable_png',
+ !empty($sourceMeta ['url'])
+ && !empty($sourceMeta ['absolute_path'])
+ && (int) $sourceMeta ['type'] === 3,
+ json_encode($sourceMeta)
+ );
+
+ $sourceMeta ['alt'] = 'Prepared image title';
+ $publicMeta = seometataginfo_prepare_og_image_meta(
+ BLOG_BASEURL,
+ $sourceMeta,
+ isset($sourceMeta ['relative_path']) ? (string) $sourceMeta ['relative_path'] : ''
+ );
+ $url = isset($publicMeta ['url']) ? (string) $publicMeta ['url'] : '';
+ $query = array();
+ $queryString = parse_url($url, PHP_URL_QUERY);
+ if (is_string($queryString)) {
+ parse_str($queryString, $query);
+ }
+
+ format_assert(
+ 'content_image_is_published_via_dynamic_og_endpoint',
+ isset($query [SEOMETA_OGIMAGE_QUERY_VAR])
+ && (string) $query [SEOMETA_OGIMAGE_QUERY_VAR] === '1'
+ && isset($query [SEOMETA_OGIMAGE_SOURCE_QUERY_VAR])
+ && (string) $query [SEOMETA_OGIMAGE_SOURCE_QUERY_VAR] === IMAGES_DIR . 'format-source.png',
+ $url
+ );
+ format_assert(
+ 'dynamic_content_meta_advertises_1200x630',
+ (int) $publicMeta ['width'] === 1200 && (int) $publicMeta ['height'] === 630,
+ 'width=' . (int) $publicMeta ['width'] . '; height=' . (int) $publicMeta ['height']
+ );
+ format_assert(
+ 'dynamic_content_meta_preserves_image_alt_text',
+ isset($publicMeta ['alt']) && (string) $publicMeta ['alt'] === 'Prepared image title',
+ isset($publicMeta ['alt']) ? (string) $publicMeta ['alt'] : ''
+ );
+ format_assert(
+ 'og_image_alt_prefers_selected_image_title',
+ seometataginfo_get_og_image_alt_text(array('alt' => 'Selected title'), 'Regression') === 'Selected title'
+ );
+ format_assert(
+ 'og_image_alt_falls_back_to_site_title',
+ seometataginfo_get_og_image_alt_text(array('alt' => ''), 'Regression') === 'Regression'
+ );
+ format_assert(
+ 'og_image_alt_uses_preview_only_when_site_title_is_empty',
+ seometataginfo_get_og_image_alt_text(array('alt' => ''), '') === 'Preview'
+ );
+
+ $_GET [SEOMETA_OGIMAGE_SOURCE_QUERY_VAR] = IMAGES_DIR . 'format-source.png';
+ $requestInfo = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ $requestMeta = isset($requestInfo ['image_info']) && is_array($requestInfo ['image_info']) ? $requestInfo ['image_info'] : array();
+ format_assert(
+ 'dynamic_endpoint_rehydrates_validated_content_source',
+ !empty($requestInfo ['requested'])
+ && isset($requestMeta ['absolute_path'])
+ && realpath((string) $requestMeta ['absolute_path']) === realpath($sourceFile),
+ isset($requestMeta ['relative_path']) ? (string) $requestMeta ['relative_path'] : ''
+ );
+
+ // Reproduce the reported query-shape without depending on the instance-only
+ // avm-gelaende.png file: a URL copied from HTML source keeps "&"
+ // literally. PHP then exposes keys such as "amp;seometa_ogsource".
+ $literalHtmlQuery = 'seometa_ogimage=1&v=1786722663&seometa_ogsource='
+ . rawurlencode(IMAGES_DIR . $htmlEscapedFixtureName);
+ parse_str($literalHtmlQuery, $_GET);
+ $literalRequest = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ $literalMeta = isset($literalRequest ['image_info']) && is_array($literalRequest ['image_info'])
+ ? $literalRequest ['image_info']
+ : array();
+ format_assert(
+ 'literal_html_escaped_query_rehydrates_reported_content_source',
+ seometataginfo_is_og_image_request()
+ && !empty($literalRequest ['requested'])
+ && isset($literalMeta ['absolute_path'])
+ && realpath((string) $literalMeta ['absolute_path']) === realpath($htmlEscapedFixtureFile),
+ json_encode(array('get' => $_GET, 'request' => $literalRequest))
+ );
+
+ // Some copy/paste chains escape an already escaped URL a second time.
+ $doubleEscapedQuery = 'seometa_ogimage=1&v=1786722663&seometa_ogsource='
+ . rawurlencode(IMAGES_DIR . $htmlEscapedFixtureName);
+ parse_str($doubleEscapedQuery, $_GET);
+ $doubleEscapedRequest = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ $doubleEscapedMeta = isset($doubleEscapedRequest ['image_info']) && is_array($doubleEscapedRequest ['image_info'])
+ ? $doubleEscapedRequest ['image_info']
+ : array();
+ format_assert(
+ 'double_html_escaped_query_rehydrates_content_source',
+ seometataginfo_is_og_image_request()
+ && !empty($doubleEscapedRequest ['requested'])
+ && isset($doubleEscapedMeta ['absolute_path'])
+ && realpath((string) $doubleEscapedMeta ['absolute_path']) === realpath($htmlEscapedFixtureFile),
+ json_encode(array('get' => $_GET, 'request' => $doubleEscapedRequest))
+ );
+
+ // The OG flag itself can be affected when another query parameter precedes
+ // it in a copied HTML URL.
+ $escapedFlagQuery = 'foo=bar&seometa_ogimage=1&seometa_ogsource='
+ . rawurlencode(IMAGES_DIR . $htmlEscapedFixtureName);
+ parse_str($escapedFlagQuery, $_GET);
+ $escapedFlagRequest = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ format_assert(
+ 'literal_html_escaped_og_flag_is_recognized',
+ seometataginfo_is_og_image_request() && !empty($escapedFlagRequest ['image_info']),
+ json_encode($_GET)
+ );
+
+ // An exact parameter always wins over any escaped alias. This prevents an
+ // alternate alias from overriding a normal request.
+ $_GET = array(
+ SEOMETA_OGIMAGE_QUERY_VAR => '1',
+ SEOMETA_OGIMAGE_SOURCE_QUERY_VAR => IMAGES_DIR . $htmlEscapedFixtureName,
+ 'amp;' . SEOMETA_OGIMAGE_SOURCE_QUERY_VAR => IMAGES_DIR . '../config/invalid.png'
+ );
+ $precedenceRequest = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ $precedenceMeta = isset($precedenceRequest ['image_info']) && is_array($precedenceRequest ['image_info'])
+ ? $precedenceRequest ['image_info']
+ : array();
+ format_assert(
+ 'exact_query_parameter_precedes_escaped_alias',
+ isset($precedenceMeta ['absolute_path'])
+ && realpath((string) $precedenceMeta ['absolute_path']) === realpath($htmlEscapedFixtureFile),
+ json_encode($_GET)
+ );
+
+ // A copied absolute blog-root path should normalize to the same local file.
+ $_GET = array(
+ SEOMETA_OGIMAGE_QUERY_VAR => '1',
+ 'amp;' . SEOMETA_OGIMAGE_SOURCE_QUERY_VAR => BLOG_ROOT . IMAGES_DIR . $htmlEscapedFixtureName
+ );
+ $blogRootRequest = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ $blogRootMeta = isset($blogRootRequest ['image_info']) && is_array($blogRootRequest ['image_info'])
+ ? $blogRootRequest ['image_info']
+ : array();
+ format_assert(
+ 'escaped_alias_with_blog_root_path_is_normalized',
+ isset($blogRootMeta ['absolute_path'])
+ && realpath((string) $blogRootMeta ['absolute_path']) === realpath($htmlEscapedFixtureFile),
+ json_encode($_GET)
+ );
+
+ // Invalid escaped aliases must be treated as explicitly requested sources,
+ // so they return no source instead of silently falling back to the theme.
+ $_GET = array(
+ SEOMETA_OGIMAGE_QUERY_VAR => '1',
+ 'amp;' . SEOMETA_OGIMAGE_SOURCE_QUERY_VAR => 'fp-content/images/../config/secrets.php'
+ );
+ $escapedTraversal = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ format_assert(
+ 'escaped_alias_traversal_is_rejected_without_theme_fallback',
+ !empty($escapedTraversal ['requested']) && empty($escapedTraversal ['image_info']),
+ json_encode($escapedTraversal)
+ );
+
+ $_GET = array(
+ SEOMETA_OGIMAGE_SOURCE_QUERY_VAR => 'fp-content/images/../config/secrets.php'
+ );
+ $rejected = seometataginfo_get_requested_content_og_image_info(BLOG_BASEURL);
+ format_assert(
+ 'dynamic_endpoint_rejects_path_traversal',
+ !empty($rejected ['requested']) && empty($rejected ['image_info']),
+ json_encode($rejected)
+ );
+ unset($_GET [SEOMETA_OGIMAGE_SOURCE_QUERY_VAR]);
+
+ $geometryCases = array(
+ 'landscape_16_9' => array(1600, 900, 1120, 630, 40, 0),
+ 'portrait_9_16' => array(900, 1600, 354, 630, 423, 0),
+ 'square' => array(1000, 1000, 630, 630, 285, 0),
+ 'already_1200x630' => array(1200, 630, 1200, 630, 0, 0)
+ );
+
+ foreach ($geometryCases as $name => $case) {
+ $box = seometataginfo_calculate_og_contain_box($case [0], $case [1], 1200, 630);
+ $sourceRatio = $case [0] / $case [1];
+ $destRatio = $box ['width'] / $box ['height'];
+ $ratioError = abs($sourceRatio - $destRatio);
+
+ format_assert(
+ 'contain_geometry_' . $name,
+ $box ['width'] === $case [2]
+ && $box ['height'] === $case [3]
+ && $box ['x'] === $case [4]
+ && $box ['y'] === $case [5]
+ && $box ['width'] <= 1200
+ && $box ['height'] <= 630
+ && $ratioError < 0.003,
+ json_encode($box) . '; ratio_error=' . $ratioError
+ );
+ }
+
+ $remoteMeta = seometataginfo_content_remote_image_meta('https://cdn.example.test/original.jpg');
+ $remotePublic = seometataginfo_prepare_og_image_meta(BLOG_BASEURL, $remoteMeta, '');
+ format_assert(
+ 'remote_image_remains_direct_without_server_side_fetch',
+ isset($remotePublic ['url']) && $remotePublic ['url'] === 'https://cdn.example.test/original.jpg'
+ && (int) $remotePublic ['width'] === 0
+ && (int) $remotePublic ['height'] === 0,
+ json_encode($remotePublic)
+ );
+
+} catch (Throwable $e) {
+ $failed++;
+ $results [] = array(
+ 'name' => 'format_validation_exception',
+ 'status' => 'FAIL',
+ 'details' => get_class($e) . ': ' . $e->getMessage()
+ );
+}
+
+format_remove_tree($fixtureRoot);
+
+$summary = array(
+ 'php_version' => PHP_VERSION,
+ 'total' => count($results),
+ 'passed' => count($results) - $failed,
+ 'failed' => $failed,
+ 'results' => $results
+);
+
+echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+exit($failed === 0 ? 0 : 1);
+?>
diff --git a/fp-plugins/seometataginfo/regression-test/validate_target_parser.ph_ b/fp-plugins/seometataginfo/regression-test/validate_target_parser.ph_
new file mode 100644
index 00000000..c84d3bf8
--- /dev/null
+++ b/fp-plugins/seometataginfo/regression-test/validate_target_parser.ph_
@@ -0,0 +1,168 @@
+ true,
+ 'comments' => false,
+ 'editor' => false,
+ 'maskattachs' => false,
+ 'url-maxlen' => 40
+ );
+ }
+ return array();
+}
+function add_filter($tag, $callback, $priority = 10, $acceptedArgs = 1) {
+ if (!isset($GLOBALS ['regression_filters'] [$tag])) {
+ $GLOBALS ['regression_filters'] [$tag] = array();
+ }
+ $GLOBALS ['regression_filters'] [$tag] [] = $callback;
+ return true;
+}
+function add_action($tag, $callback, $priority = 10, $acceptedArgs = 1) {
+ return add_filter($tag, $callback, $priority, $acceptedArgs);
+}
+function apply_filters($tag, $value) {
+ if (!empty($GLOBALS ['regression_filters'] [$tag])) {
+ foreach ($GLOBALS ['regression_filters'] [$tag] as $callback) {
+ $value = call_user_func($callback, $value);
+ }
+ }
+ return $value;
+}
+function wp_specialchars($value, $quotes = 0) {
+ return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
+}
+function is_apcu_on() {
+ return false;
+}
+function lang_load($id) {
+ return array();
+}
+
+if (!chdir($root)) {
+ fwrite(STDERR, "Cannot switch to FlatPress root.\n");
+ exit(2);
+}
+
+require $root . 'fp-plugins/bbcode/plugin.bbcode.php';
+require $root . 'fp-plugins/photoswipe/plugin.photoswipe.php';
+
+// In FlatPress this happens on the init hook.
+PhotoSwipeFunctions::initializePluginTags();
+
+require $root . 'fp-plugins/seometataginfo/inc/og-content-image.php';
+
+$results = array();
+$failed = 0;
+
+function parser_regression_assert($name, $ok, $details = '') {
+ global $results, $failed;
+ $results [] = array(
+ 'name' => $name,
+ 'status' => $ok ? 'PASS' : 'FAIL',
+ 'details' => (string) $details
+ );
+ if (!$ok) {
+ $failed++;
+ }
+}
+
+$cases = array(
+ 'actual_bbcode_img_detected' => array('[img="images/x.jpg" width="20"]', 'img', 'images/x.jpg'),
+ 'actual_photoswipe_gallery_detected' => array('[gallery="images/g"]', 'gallery', 'images/g'),
+ 'actual_photoswipeimage_alias_detected' => array('[photoswipeimage="images/x.jpg"]', 'photoswipeimage', 'images/x.jpg'),
+ 'actual_photoswipegallery_alias_detected' => array('[photoswipegallery="images/g"]', 'photoswipegallery', 'images/g')
+);
+
+foreach ($cases as $name => $case) {
+ $probe = seometataginfo_content_probe_media($case [0]);
+ $token = isset($probe ['tokens'] [0]) && is_array($probe ['tokens'] [0]) ? $probe ['tokens'] [0] : array();
+ $actualTag = isset($token ['tag']) ? (string) $token ['tag'] : '';
+ $actualSource = isset($token ['attributes'] ['default']) ? (string) $token ['attributes'] ['default'] : '';
+ parser_regression_assert(
+ $name,
+ count($probe ['tokens']) === 1 && $actualTag === $case [1] && $actualSource === $case [2],
+ 'tag=' . $actualTag . '; source=' . $actualSource
+ );
+}
+
+$probe = seometataginfo_content_probe_media('[img="images/x.jpg" title="Parser title" alt="Ignored alt" width="20"]');
+$token = isset($probe ['tokens'] [0]) && is_array($probe ['tokens'] [0]) ? $probe ['tokens'] [0] : array();
+$actualTitle = isset($token ['attributes'] ['title']) ? (string) $token ['attributes'] ['title'] : '';
+parser_regression_assert(
+ 'actual_photoswipe_overridden_img_preserves_title_attribute',
+ count($probe ['tokens']) === 1 && $actualTitle === 'Parser title',
+ 'title=' . $actualTitle
+);
+
+
+$probe = seometataginfo_content_probe_media('[code][img="images/x.jpg"][/code]');
+parser_regression_assert(
+ 'actual_bbcode_code_blocks_nested_image',
+ empty($probe ['tokens']),
+ 'tokens=' . count($probe ['tokens']) . '; html=' . (isset($probe ['html']) ? $probe ['html'] : '')
+);
+
+// The SEO probe replaces PhotoSwipe media callbacks on a clone. It must not
+// advance PhotoSwipe's request-local data index.
+$beforeIndex = null;
+$afterIndex = null;
+try {
+ $property = new ReflectionProperty('PhotoSwipeFunctions', 'lastusedDataIndex');
+ $property->setAccessible(true);
+ $beforeIndex = $property->getValue();
+ seometataginfo_content_probe_media('[img="images/x.jpg"] [gallery="images/g"]');
+ $afterIndex = $property->getValue();
+} catch (Exception $e) {
+ $beforeIndex = 'reflection-error';
+ $afterIndex = $e->getMessage();
+}
+parser_regression_assert(
+ 'probe_does_not_advance_photoswipe_index',
+ $beforeIndex !== 'reflection-error' && $beforeIndex === $afterIndex,
+ 'before=' . (string) $beforeIndex . '; after=' . (string) $afterIndex
+);
+
+$summary = array(
+ 'php_version' => PHP_VERSION,
+ 'total' => count($results),
+ 'passed' => count($results) - $failed,
+ 'failed' => $failed,
+ 'results' => $results
+);
+
+echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+exit(min(1, (int)$failed));
+?>