This commit is contained in:
Javier Casares 2026-02-19 11:15:55 +00:00
commit f7702f2872
25 changed files with 6453 additions and 0 deletions

38
CHANGELOG.md Normal file
View file

@ -0,0 +1,38 @@
# Changelog
All notable changes to OpenGraph (by ROBOTSTXT) will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-02-18
### Added
- Automatic format detection for AVIF, WebP, GIF, BMP, SVG, and TIFF featured images
- HTTP HEAD-based resolution of compatible JPEG/PNG alternatives
- Postmeta caching (`_og_image_fallback_url`) with automatic invalidation on featured image change
- Negative caching via transients (1-hour TTL) to avoid repeated failed HEAD requests
- Open Graph meta tags: `og:image`, `og:image:secure_url`, `og:image:width`, `og:image:height`, `og:image:type`
- Direct `wp_head` tag injection (when no SEO plugin is active)
- Yoast SEO integration via `wpseo_opengraph_image` filter
- RankMath integration via `rank_math/opengraph/facebook/og_image` filter
- Global fallback image setting (configurable in Settings > OpenGraph)
- Taxonomy archive image support via `robotstxt_og_taxonomy_image` filter
- Admin settings page (Settings > OpenGraph) with three tabs: Settings, Tools, Diagnostics
- WP-CLI commands: `wp og-fallback resolve` and `wp og-fallback clear-cache`
- REST API: `POST /wp-json/robotstxt-og/v1/resolve/{post_id}` and `GET /wp-json/robotstxt-og/v1/status/{post_id}`
- Developer filters: `robotstxt_og_external_image_enabled`, `robotstxt_og_external_image_timeout`, `robotstxt_og_taxonomy_image`, `robotstxt_og_enable_logging`
- Auto-updater via Gitea (`robotstxt-updater.php`)
- Multisite compatible with per-site configuration
- Full internationalization support (text domain: `robotstxt-og`)
- `uninstall.php` with optional data deletion on plugin removal
### Security
- Nonce verification on all forms and state-changing actions
- `manage_options` capability check on all admin pages, REST endpoints, and WP-CLI commands
- Input sanitization via WordPress APIs throughout
- Output escaping on all dynamic HTML
- WordPress database APIs exclusively (no direct SQL writes; `$wpdb->prepare()` for reads)
- CSRF protection on all state-changing actions
[1.0.0]: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/tag/1.0.0

674
LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

132
README.md Normal file
View file

@ -0,0 +1,132 @@
# OpenGraph (by ROBOTSTXT)
> Intelligent Open Graph image fallback for social media crawlers.
Automatically detects when a post's featured image uses a modern format (AVIF, WebP) that social media crawlers cannot render, and resolves a compatible JPEG/PNG alternative to use in `og:image` meta tags.
## Requirements
- **WordPress:** 6.7+
- **PHP:** 8.28.5
- **WP-CLI:** 2.x (optional, for CLI tools)
## Installation
```bash
# From the plugin directory
composer install
```
Activate the plugin through the WordPress admin or WP-CLI:
```bash
wp plugin activate robotstxt-og --allow-root
```
## Architecture
```
robotstxt-og/
├── robotstxt-og.php # Main plugin file, constants, bootstrap
├── includes/
│ ├── class-robotstxt-og-image-fallback.php # Singleton, orchestration
│ ├── class-robotstxt-og-image-resolver.php # Image detection, HEAD requests, caching
│ ├── class-robotstxt-og-tags.php # OG tag injection, SEO plugin filters
│ ├── class-robotstxt-og-cli.php # WP-CLI commands
│ └── class-robotstxt-og-rest-api.php # REST API endpoints
├── admin/
│ ├── class-robotstxt-og-admin-settings.php # Admin page, settings, cache actions
│ └── views/settings-page.php # Settings page template (tabbed)
├── assets/
│ ├── admin.js # Media uploader integration
│ └── admin.css # Admin styles
├── languages/
│ └── robotstxt-og.pot # Translation template
├── docs/
│ ├── FILTERS-HOOKS.md # Developer filter/hook reference
│ ├── WP-CLI.md # WP-CLI command reference
│ └── IDEA.md # Original concept document
├── bin/
│ └── deploy.sh # Build and ZIP packaging script
├── robotstxt-updater.php # Auto-updater (Gitea-based)
├── uninstall.php # Data cleanup on uninstall
├── readme.txt # WordPress.org plugin readme
└── changelog.txt # Full changelog (WordPress.org format)
```
## Development
### Code Standards
```bash
# Lint
vendor/bin/phpcs .
# Auto-fix
vendor/bin/phpcbf .
# PHP compatibility check (8.28.5)
vendor/bin/phpcs -p . --standard=PHPCompatibility --runtime-set testVersion 8.2-8.5
```
### Regenerate translation template
```bash
wp i18n make-pot . languages/robotstxt-og.pot --domain=robotstxt-og --exclude=vendor,node_modules,tests --allow-root
```
### Build release ZIP
```bash
bash bin/deploy.sh
```
The ZIP is created at `../robotstxt-og-{version}.zip` (i.e. in `wp-content/plugins/`).
## WP-CLI Commands
```bash
# Resolve fallback for a single post
wp og-fallback resolve 123
# Re-resolve all posts with featured images
wp og-fallback resolve --all
# Dry-run (no changes saved)
wp og-fallback resolve --all --dry-run
# Clear cache for a single post
wp og-fallback clear-cache 123
# Clear all caches
wp og-fallback clear-cache --all
```
## REST API
All endpoints require the `manage_options` capability.
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/wp-json/robotstxt-og/v1/resolve/{post_id}` | Force re-resolve fallback image |
| `GET` | `/wp-json/robotstxt-og/v1/status/{post_id}` | Get current cache status |
## Filters
| Filter | Default | Description |
|--------|---------|-------------|
| `robotstxt_og_external_image_enabled` | `true` | Enable/disable external image resolution |
| `robotstxt_og_external_image_timeout` | `5` | HEAD request timeout (seconds) |
| `robotstxt_og_taxonomy_image` | `''` | Image URL for taxonomy archive pages |
| `robotstxt_og_enable_logging` | `false` | Enable debug logging to `debug.log` |
Full documentation: [`docs/FILTERS-HOOKS.md`](docs/FILTERS-HOOKS.md)
## License
GPL-3.0-or-later — see [LICENSE](https://www.gnu.org/licenses/gpl-3.0.txt)
## Author
**ROBOTSTXT** — [robotstxt.es](https://www.robotstxt.es/)
Contributors: javiercasares, robotstxt

View file

@ -0,0 +1,733 @@
<?php
/**
* Admin Settings Class
*
* Handles administration interface and settings management.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Admin_Settings
*
* Manages plugin settings page and administrative functions.
*
* @since 1.0.0
*/
class Robotstxt_OG_Admin_Settings {
/**
* Image resolver instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Resolver
*/
private Robotstxt_OG_Image_Resolver $resolver;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
*/
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
$this->resolver = $resolver;
}
/**
* Initialize admin hooks.
*
* @since 1.0.0
*
* @return void
*/
public function init(): void {
add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_init', array( $this, 'handle_clear_cache' ) );
add_action( 'admin_init', array( $this, 'handle_resolve_all' ) );
add_action( 'admin_init', array( $this, 'handle_clear_single' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
}
/**
* Handle clear single post cache action.
*
* @since 1.0.0
*
* @return void
*/
public function handle_clear_single(): void {
// Check if this is a single clear request.
$post_id_raw = filter_input( INPUT_GET, 'robotstxt_og_clear_single', FILTER_SANITIZE_NUMBER_INT );
if ( null === $post_id_raw ) {
return;
}
$post_id = absint( $post_id_raw );
if ( $post_id <= 0 ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_single_' . $post_id ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Clear single post cache.
$this->resolver->clear_cache( $post_id );
// Add admin notice.
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_single_cleared',
/* translators: %d: post ID */
sprintf( __( 'Successfully cleared cached fallback URL for post #%d.', 'robotstxt-og' ), $post_id ),
'success'
);
// Redirect back to diagnostics tab.
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
wp_safe_redirect(
add_query_arg( 'tab', 'diagnostics', remove_query_arg( array( 'robotstxt_og_clear_single', '_wpnonce' ), $page_url ) )
);
exit;
}
/**
* Add settings page to WordPress admin menu.
*
* @since 1.0.0
*
* @return void
*/
public function add_settings_page(): void {
add_options_page(
__( 'OpenGraph Settings', 'robotstxt-og' ),
__( 'OpenGraph', 'robotstxt-og' ),
'manage_options',
'robotstxt-og-settings',
array( $this, 'render_settings_page' )
);
}
/**
* Register plugin settings.
*
* @since 1.0.0
*
* @return void
*/
public function register_settings(): void {
// Register settings.
register_setting(
'robotstxt_og_settings',
'robotstxt_og_fallback_image',
array(
'type' => 'string',
'sanitize_callback' => array( $this, 'sanitize_image_url' ),
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_homepage_image',
array(
'type' => 'string',
'sanitize_callback' => array( $this, 'sanitize_image_url' ),
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_enable_facebook',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_enable_twitter',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_twitter_card_type',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'default' => 'summary_large_image',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_twitter_site',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_delete_data_on_uninstall',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
)
);
// --- General Settings section ---
add_settings_section(
'robotstxt_og_main_section',
__( 'General Settings', 'robotstxt-og' ),
array( $this, 'render_main_section' ),
'robotstxt-og-settings'
);
add_settings_field(
'robotstxt_og_fallback_image',
__( 'Global Fallback Image', 'robotstxt-og' ),
array( $this, 'render_fallback_image_field' ),
'robotstxt-og-settings',
'robotstxt_og_main_section'
);
add_settings_field(
'robotstxt_og_homepage_image',
__( 'Homepage Image', 'robotstxt-og' ),
array( $this, 'render_homepage_image_field' ),
'robotstxt-og-settings',
'robotstxt_og_main_section'
);
add_settings_field(
'robotstxt_og_delete_data_on_uninstall',
__( 'Data Management', 'robotstxt-og' ),
array( $this, 'render_delete_data_field' ),
'robotstxt-og-settings',
'robotstxt_og_main_section'
);
// --- Social Media Tags section ---
add_settings_section(
'robotstxt_og_social_section',
__( 'Social Media Tags', 'robotstxt-og' ),
array( $this, 'render_social_section' ),
'robotstxt-og-settings'
);
add_settings_field(
'robotstxt_og_enable_facebook',
__( 'Facebook / OG Tags', 'robotstxt-og' ),
array( $this, 'render_enable_facebook_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
add_settings_field(
'robotstxt_og_enable_twitter',
__( 'Twitter Card Tags', 'robotstxt-og' ),
array( $this, 'render_enable_twitter_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
add_settings_field(
'robotstxt_og_twitter_card_type',
__( 'Twitter Card Type', 'robotstxt-og' ),
array( $this, 'render_twitter_card_type_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
add_settings_field(
'robotstxt_og_twitter_site',
__( 'Twitter/X Site Handle', 'robotstxt-og' ),
array( $this, 'render_twitter_site_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
}
/**
* Sanitize and validate an image URL for use as an OG image.
*
* Validates URL format and checks compatibility (JPEG/PNG). For AVIF/WebP
* and other incompatible formats, attempts to find a JPEG/PNG alternative.
* Returns empty string and adds a settings error if the URL is invalid or
* no compatible version can be found.
*
* @since 1.2.0
*
* @param string $raw Raw value from the settings form.
* @return string Validated, compatible image URL or empty string.
*/
public function sanitize_image_url( string $raw ): string {
$url = esc_url_raw( trim( $raw ) );
if ( empty( $url ) ) {
return '';
}
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_invalid_url',
__( 'The image URL is not valid. Please enter a full URL starting with https://.', 'robotstxt-og' ),
'error'
);
return '';
}
$compatible = $this->resolver->ensure_compatible_format( $url );
if ( empty( $compatible ) ) {
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_incompatible_format',
__( 'The image URL is not in a compatible format (JPEG or PNG), and no compatible alternative could be found.', 'robotstxt-og' ),
'error'
);
return '';
}
return $compatible;
}
/**
* Render main settings section description.
*
* @since 1.0.0
*
* @return void
*/
public function render_main_section(): void {
echo '<p>';
esc_html_e( 'Configure OpenGraph image fallback behavior.', 'robotstxt-og' );
echo '</p>';
}
/**
* Render fallback image field.
*
* @since 1.0.0
*
* @return void
*/
public function render_fallback_image_field(): void {
$image_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
?>
<input
type="url"
id="robotstxt_og_fallback_image"
name="robotstxt_og_fallback_image"
value="<?php echo esc_url( $image_url ); ?>"
class="large-text"
placeholder="https://example.com/fallback.jpg"
/>
<p class="description">
<?php esc_html_e( 'Used as Open Graph image when a post has no featured image or when no compatible format (JPEG/PNG) is found. Must be a direct URL to a JPEG or PNG file.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render homepage image field.
*
* @since 1.1.0
*
* @return void
*/
public function render_homepage_image_field(): void {
$image_url = (string) get_option( 'robotstxt_og_homepage_image', '' );
?>
<input
type="url"
id="robotstxt_og_homepage_image"
name="robotstxt_og_homepage_image"
value="<?php echo esc_url( $image_url ); ?>"
class="large-text"
placeholder="https://example.com/homepage.jpg"
/>
<p class="description">
<?php esc_html_e( 'Dedicated Open Graph image for the site homepage. Falls back to the Global Fallback Image if not set. Must be a direct URL to a JPEG or PNG file.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render delete data checkbox field.
*
* @since 1.0.0
*
* @return void
*/
public function render_delete_data_field(): void {
$delete_data = get_option( 'robotstxt_og_delete_data_on_uninstall', false );
?>
<label>
<input type="checkbox" name="robotstxt_og_delete_data_on_uninstall" value="1" <?php checked( $delete_data, true ); ?> />
<?php esc_html_e( 'Delete all plugin data when uninstalling', 'robotstxt-og' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Warning: This will permanently delete all cached fallback URLs and plugin settings. This action cannot be undone.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render social media tags section description.
*
* @since 1.1.0
*
* @return void
*/
public function render_social_section(): void {
echo '<p>';
esc_html_e( 'Choose which social media meta tag groups to output and configure their behavior.', 'robotstxt-og' );
echo '</p>';
}
/**
* Render enable Facebook/OG tags checkbox.
*
* @since 1.1.0
*
* @return void
*/
public function render_enable_facebook_field(): void {
$enabled = get_option( 'robotstxt_og_enable_facebook', true );
?>
<label>
<input type="checkbox" name="robotstxt_og_enable_facebook" value="1" <?php checked( $enabled, true ); ?> />
<?php esc_html_e( 'Enable Facebook / Open Graph image tags', 'robotstxt-og' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, and og:image:alt meta tags.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render enable Twitter Card tags checkbox.
*
* @since 1.1.0
*
* @return void
*/
public function render_enable_twitter_field(): void {
$enabled = get_option( 'robotstxt_og_enable_twitter', true );
?>
<label>
<input type="checkbox" name="robotstxt_og_enable_twitter" value="1" <?php checked( $enabled, true ); ?> />
<?php esc_html_e( 'Enable Twitter Card image tags', 'robotstxt-og' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Outputs twitter:card, twitter:site, and twitter:image meta tags.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render Twitter card type select field.
*
* @since 1.1.0
*
* @return void
*/
public function render_twitter_card_type_field(): void {
$card_type = get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
$options = array(
'summary_large_image' => __( 'Summary with large image', 'robotstxt-og' ),
'summary' => __( 'Summary (small image)', 'robotstxt-og' ),
);
?>
<select name="robotstxt_og_twitter_card_type" id="robotstxt_og_twitter_card_type">
<?php foreach ( $options as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $card_type, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php esc_html_e( 'Controls the twitter:card meta tag value. "Summary with large image" is recommended for most sites.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render Twitter/X site handle field.
*
* @since 1.2.0
*
* @return void
*/
public function render_twitter_site_field(): void {
$handle = (string) get_option( 'robotstxt_og_twitter_site', '' );
?>
<input
type="text"
id="robotstxt_og_twitter_site"
name="robotstxt_og_twitter_site"
value="<?php echo esc_attr( $handle ); ?>"
placeholder="@example"
class="regular-text"
/>
<p class="description">
<?php esc_html_e( "Your site's Twitter/X account handle (e.g. @example). Used for the twitter:site meta tag.", 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render settings page.
*
* @since 1.0.0
*
* @return void
*/
public function render_settings_page(): void {
// Check user capabilities.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Include view file.
require_once ROBOTSTXT_OG_PATH . 'admin/views/settings-page.php';
}
/**
* Handle clear cache action.
*
* @since 1.0.0
*
* @return void
*/
public function handle_clear_cache(): void {
// Check if this is a cache clear request.
$clear_cache = filter_input( INPUT_GET, 'robotstxt_og_clear_cache', FILTER_SANITIZE_SPECIAL_CHARS );
if ( null === $clear_cache ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_cache' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Clear all caches.
$count = $this->clear_all_caches();
// Add admin notice.
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_cache_cleared',
/* translators: %d: number of cleared cache entries */
sprintf( __( 'Successfully cleared %d cached fallback URLs.', 'robotstxt-og' ), $count ),
'success'
);
// Redirect back to tools tab.
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
wp_safe_redirect(
add_query_arg( 'tab', 'tools', remove_query_arg( array( 'robotstxt_og_clear_cache', '_wpnonce' ), $page_url ) )
);
exit;
}
/**
* Handle resolve all images action.
*
* @since 1.0.0
*
* @return void
*/
public function handle_resolve_all(): void {
// Check if this is a resolve all request.
$resolve_all = filter_input( INPUT_GET, 'robotstxt_og_resolve_all', FILTER_SANITIZE_SPECIAL_CHARS );
if ( null === $resolve_all ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_resolve_all' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Resolve all images.
$result = $this->resolve_all_images();
// Add admin notice.
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_images_resolved',
/* translators: 1: number of successful resolutions 2: number of failed resolutions */
sprintf( __( 'Batch resolution complete. Success: %1$d, Failed: %2$d', 'robotstxt-og' ), $result['success'], $result['failed'] ),
$result['failed'] > 0 ? 'warning' : 'success'
);
// Redirect back to tools tab.
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
wp_safe_redirect(
add_query_arg( 'tab', 'tools', remove_query_arg( array( 'robotstxt_og_resolve_all', '_wpnonce' ), $page_url ) )
);
exit;
}
/**
* Clear all cached fallback URLs.
*
* @since 1.0.0
*
* @return int Number of entries deleted.
*/
private function clear_all_caches(): int {
global $wpdb;
// Bulk-delete all cache entries by meta key. No WP API performs this
// in a single query while also returning the affected row count.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->delete(
$wpdb->postmeta,
array( 'meta_key' => '_og_image_fallback_url' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' )
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return $result ? (int) $result : 0;
}
/**
* Resolve all images in batch.
*
* @since 1.0.0
*
* @return array{success: int, failed: int} Result counts.
*/
private function resolve_all_images(): array {
// Query all posts with featured images.
$posts = get_posts(
array(
'post_type' => 'any',
'post_status' => 'any',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_thumbnail_id',
'compare' => 'EXISTS',
),
),
'fields' => 'ids',
)
);
$success = 0;
$failed = 0;
foreach ( $posts as $post_id ) {
// Clear cache first.
$this->resolver->clear_cache( $post_id );
// Resolve image.
$url = $this->resolver->resolve_image( $post_id );
if ( ! empty( $url ) ) {
++$success;
} else {
++$failed;
}
}
return array(
'success' => $success,
'failed' => $failed,
);
}
/**
* Enqueue admin assets.
*
* @since 1.0.0
*
* @param string $hook Current admin page hook.
* @return void
*/
public function enqueue_admin_assets( string $hook ): void {
// Only on our settings page.
if ( 'settings_page_robotstxt-og-settings' !== $hook ) {
return;
}
// Enqueue custom admin JS.
wp_enqueue_script(
'robotstxt-og-admin',
ROBOTSTXT_OG_URL . 'assets/admin.js',
array( 'jquery' ),
ROBOTSTXT_OG_VERSION,
true
);
// Pass translated strings to JavaScript.
wp_localize_script(
'robotstxt-og-admin',
'robotstxt_og_admin',
array(
'confirm_clear_cache' => __( 'Are you sure you want to clear all cached fallback URLs? This action cannot be undone.', 'robotstxt-og' ),
'confirm_resolve_all' => __( 'Are you sure you want to re-resolve all images? This may take some time on large sites.', 'robotstxt-og' ),
)
);
// Enqueue custom admin CSS.
wp_enqueue_style(
'robotstxt-og-admin',
ROBOTSTXT_OG_URL . 'assets/admin.css',
array(),
ROBOTSTXT_OG_VERSION
);
}
}

View file

@ -0,0 +1,389 @@
<?php
/**
* Admin Settings Page View
*
* Template for the OpenGraph settings page with tab navigation.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
$valid_tabs = array( 'settings', 'tools', 'diagnostics' );
if ( ! in_array( $active_tab, $valid_tabs, true ) ) {
$active_tab = 'settings';
}
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php settings_errors( 'robotstxt_og_messages' ); ?>
<nav class="nav-tab-wrapper" aria-label="<?php esc_attr_e( 'Settings tabs', 'robotstxt-og' ); ?>">
<a href="<?php echo esc_url( add_query_arg( 'tab', 'settings', $page_url ) ); ?>"
class="nav-tab <?php echo 'settings' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'Settings', 'robotstxt-og' ); ?>
</a>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'tools', $page_url ) ); ?>"
class="nav-tab <?php echo 'tools' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'Tools', 'robotstxt-og' ); ?>
</a>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'diagnostics', $page_url ) ); ?>"
class="nav-tab <?php echo 'diagnostics' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'Diagnostics', 'robotstxt-og' ); ?>
</a>
</nav>
<div class="tab-content" style="margin-top: 1em;">
<?php if ( 'settings' === $active_tab ) : ?>
<form method="post" action="options.php">
<?php
settings_fields( 'robotstxt_og_settings' );
do_settings_sections( 'robotstxt-og-settings' );
submit_button( __( 'Save Settings', 'robotstxt-og' ) );
?>
</form>
<?php elseif ( 'tools' === $active_tab ) : ?>
<h2><?php esc_html_e( 'Cache Management', 'robotstxt-og' ); ?></h2>
<p><?php esc_html_e( 'Use these tools to manage cached fallback image URLs.', 'robotstxt-og' ); ?></p>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row">
<?php esc_html_e( 'Clear All Caches', 'robotstxt-og' ); ?>
</th>
<td>
<a href="
<?php
echo esc_url(
wp_nonce_url(
add_query_arg(
array(
'robotstxt_og_clear_cache' => '1',
'tab' => 'tools',
),
$page_url
),
'robotstxt_og_clear_cache'
)
);
?>
" class="button">
<?php esc_html_e( 'Clear All Cached URLs', 'robotstxt-og' ); ?>
</a>
<p class="description">
<?php esc_html_e( 'Delete all cached fallback URLs. Images will be re-resolved on next page view.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Re-resolve All Images', 'robotstxt-og' ); ?>
</th>
<td>
<a href="
<?php
echo esc_url(
wp_nonce_url(
add_query_arg(
array(
'robotstxt_og_resolve_all' => '1',
'tab' => 'tools',
),
$page_url
),
'robotstxt_og_resolve_all'
)
);
?>
" class="button">
<?php esc_html_e( 'Re-resolve All Images Now', 'robotstxt-og' ); ?>
</a>
<p class="description">
<?php esc_html_e( 'Clear cache and immediately re-resolve all posts with featured images. May take time on large sites.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
</tbody>
</table>
<h2 style="margin-top: 2em;"><?php esc_html_e( 'Plugin Information', 'robotstxt-og' ); ?></h2>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Version', 'robotstxt-og' ); ?></th>
<td><code><?php echo esc_html( ROBOTSTXT_OG_VERSION ); ?></code></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Documentation', 'robotstxt-og' ); ?></th>
<td>
<a href="https://git.robotstxt.es/ROBOTSTXT/robotstxt-og" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View Documentation', 'robotstxt-og' ); ?>
</a>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Support', 'robotstxt-og' ); ?></th>
<td>
<a href="https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/issues" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'Report an Issue', 'robotstxt-og' ); ?>
</a>
</td>
</tr>
</tbody>
</table>
<?php elseif ( 'diagnostics' === $active_tab ) : ?>
<?php
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Diagnostics page requires live data — caching would show stale counts.
$total_cached = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_image_fallback_url'
)
);
$total_with_thumbnail = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_thumbnail_id'
)
);
$posts_per_page = 20;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$current_page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1;
$offset = ( $current_page - 1 ) * $posts_per_page;
$total_pages = (int) ceil( $total_cached / $posts_per_page );
$cached_entries = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.post_id, pm.meta_value as fallback_url, p.post_title, p.post_type
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = %s
ORDER BY pm.post_id DESC
LIMIT %d OFFSET %d",
'_og_image_fallback_url',
$posts_per_page,
$offset
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
?>
<h2><?php esc_html_e( 'Statistics', 'robotstxt-og' ); ?></h2>
<table class="widefat striped" style="max-width: 480px;">
<tbody>
<tr>
<th><?php esc_html_e( 'Posts with featured images', 'robotstxt-og' ); ?></th>
<td><?php echo absint( $total_with_thumbnail ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'Posts with cached fallback URLs', 'robotstxt-og' ); ?></th>
<td><?php echo absint( $total_cached ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'Coverage', 'robotstxt-og' ); ?></th>
<td>
<?php
if ( $total_with_thumbnail > 0 ) {
echo esc_html( number_format_i18n( ( $total_cached / $total_with_thumbnail ) * 100, 1 ) ) . '%';
} else {
esc_html_e( 'N/A', 'robotstxt-og' );
}
?>
</td>
</tr>
</tbody>
</table>
<h2 style="margin-top: 2em;"><?php esc_html_e( 'Test URL', 'robotstxt-og' ); ?></h2>
<p><?php esc_html_e( 'Test whether a URL is reachable via HTTP HEAD request.', 'robotstxt-og' ); ?></p>
<form method="get" action="<?php echo esc_url( $page_url ); ?>">
<input type="hidden" name="page" value="robotstxt-og-settings" />
<input type="hidden" name="tab" value="diagnostics" />
<?php wp_nonce_field( 'robotstxt_og_test_url', '_wpnonce', false ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="test_url"><?php esc_html_e( 'URL to test', 'robotstxt-og' ); ?></label>
</th>
<td>
<?php
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$test_url_raw = isset( $_GET['test_url'] ) ? esc_url_raw( wp_unslash( $_GET['test_url'] ) ) : '';
?>
<input
type="url"
id="test_url"
name="test_url"
value="<?php echo esc_attr( $test_url_raw ); ?>"
class="regular-text"
placeholder="https://example.com/image.jpg"
/>
<?php submit_button( __( 'Test URL', 'robotstxt-og' ), 'secondary', 'submit', false ); ?>
</td>
</tr>
</table>
</form>
<?php
if ( ! empty( $test_url_raw ) ) {
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( wp_verify_nonce( $nonce, 'robotstxt_og_test_url' ) ) {
$response = wp_remote_head(
$test_url_raw,
array(
'timeout' => 5,
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . get_bloginfo( 'url' ),
)
);
echo '<h3>' . esc_html__( 'Test Result', 'robotstxt-og' ) . '</h3>';
if ( is_wp_error( $response ) ) {
echo '<div class="notice notice-error inline"><p><strong>';
esc_html_e( 'Error:', 'robotstxt-og' );
echo '</strong> ' . esc_html( $response->get_error_message() ) . '</p></div>';
} else {
$http_code = (int) wp_remote_retrieve_response_code( $response );
$http_message = wp_remote_retrieve_response_message( $response );
$notice_type = ( $http_code >= 200 && $http_code < 300 ) ? 'success' : 'error';
echo '<div class="notice notice-' . esc_attr( $notice_type ) . ' inline"><p>';
echo '<strong>' . esc_html__( 'HTTP Status:', 'robotstxt-og' ) . '</strong> ';
echo esc_html( $http_code . ' ' . $http_message ) . '</p></div>';
$headers = wp_remote_retrieve_headers( $response );
if ( ! empty( $headers ) ) {
echo '<table class="widefat striped" style="max-width:600px;margin-top:10px;">';
echo '<thead><tr><th>' . esc_html__( 'Header', 'robotstxt-og' ) . '</th>';
echo '<th>' . esc_html__( 'Value', 'robotstxt-og' ) . '</th></tr></thead><tbody>';
foreach ( $headers as $header_key => $header_value ) {
echo '<tr><td><code>' . esc_html( $header_key ) . '</code></td>';
echo '<td>' . esc_html( is_array( $header_value ) ? implode( ', ', $header_value ) : $header_value ) . '</td></tr>';
}
echo '</tbody></table>';
}
}
}
}
?>
<h2 style="margin-top:2em;">
<?php
/* translators: %d: total entries */
printf( esc_html__( 'Cached Fallback URLs (%d)', 'robotstxt-og' ), absint( $total_cached ) );
?>
</h2>
<?php if ( empty( $cached_entries ) ) : ?>
<p><?php esc_html_e( 'No cached fallback URLs found.', 'robotstxt-og' ); ?></p>
<?php else : ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Post ID', 'robotstxt-og' ); ?></th>
<th><?php esc_html_e( 'Title', 'robotstxt-og' ); ?></th>
<th><?php esc_html_e( 'Type', 'robotstxt-og' ); ?></th>
<th><?php esc_html_e( 'Cached Fallback URL', 'robotstxt-og' ); ?></th>
<th><?php esc_html_e( 'Actions', 'robotstxt-og' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $cached_entries as $cached_entry ) : ?>
<tr>
<td><?php echo absint( $cached_entry->post_id ); ?></td>
<td>
<a href="<?php echo esc_url( get_edit_post_link( $cached_entry->post_id ) ); ?>">
<?php echo esc_html( $cached_entry->post_title ); ?>
</a>
</td>
<td><code><?php echo esc_html( $cached_entry->post_type ); ?></code></td>
<td style="word-break:break-all;">
<a href="<?php echo esc_url( $cached_entry->fallback_url ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html( $cached_entry->fallback_url ); ?>
</a>
</td>
<td>
<a href="
<?php
echo esc_url(
wp_nonce_url(
add_query_arg(
array(
'robotstxt_og_clear_single' => $cached_entry->post_id,
'tab' => 'diagnostics',
),
$page_url
),
'robotstxt_og_clear_single_' . $cached_entry->post_id
)
);
?>
" class="button button-small">
<?php esc_html_e( 'Clear', 'robotstxt-og' ); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $total_pages > 1 ) : ?>
<div class="tablenav bottom">
<div class="tablenav-pages">
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $current_page,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
)
);
?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>

8
assets/admin.css Normal file
View file

@ -0,0 +1,8 @@
/**
* OpenGraph Admin CSS
*
* Styling for the admin settings page.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/

29
assets/admin.js Normal file
View file

@ -0,0 +1,29 @@
/**
* OpenGraph Admin JavaScript
*
* Confirmation dialogs for batch cache operations.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
(function($) {
'use strict';
$(document).ready(function() {
$('a[href*="robotstxt_og_clear_cache"]').on('click', function(e) {
if (!confirm(robotstxt_og_admin.confirm_clear_cache)) {
e.preventDefault();
}
});
$('a[href*="robotstxt_og_resolve_all"]').on('click', function(e) {
if (!confirm(robotstxt_og_admin.confirm_resolve_all)) {
e.preventDefault();
}
});
});
})(jQuery);

63
changelog.txt Normal file
View file

@ -0,0 +1,63 @@
== Changelog ==
= 1.0.0 =
_Release date: 2026-02-18_
**Highlights**
* Initial stable release of OpenGraph (by ROBOTSTXT).
* Outputs a complete set of Open Graph and Twitter Card meta tags, with automatic detection of incompatible image formats (AVIF, WebP, GIF, BMP, SVG, TIFF) and HTTP-based resolution of JPEG/PNG alternatives.
**Added**
* Full Open Graph meta tag output: `og:title`, `og:type`, `og:url`, `og:description`, `og:site_name`, `og:locale`, `og:image`, `og:image:width`, `og:image:height`, `og:image:type`, `og:image:alt`.
* Article-specific tags for singular posts: `article:published_time`, `article:modified_time`, `article:section`, `article:tag`.
* Twitter Card meta tag output: `twitter:card`, `twitter:site`, `twitter:image`.
* Direct tag injection via `wp_head` when no SEO plugin is active.
* Yoast SEO integration via `wpseo_opengraph_image` filter.
* RankMath integration via `rank_math/opengraph/facebook/og_image` filter.
* Automatic format detection for AVIF, WebP, GIF, BMP, SVG, and TIFF featured images.
* HTTP HEAD-based resolution of compatible JPEG/PNG alternatives.
* Postmeta caching (`_og_image_fallback_url`) with automatic invalidation when the featured image is changed or removed.
* Negative caching via transients (1-hour TTL) to avoid repeated failed HEAD requests.
* Global fallback image URL setting (Settings > OpenGraph).
* Homepage-specific image URL setting (Settings > OpenGraph).
* Image URL validation at save time: incompatible formats are auto-resolved to JPEG/PNG; invalid or unresolvable URLs are rejected with an admin notice.
* Twitter/X site handle setting for the `twitter:site` meta tag.
* Per-post Open Graph overrides: custom title and description via meta box in the post editor.
* Taxonomy archive image support via `robotstxt_og_taxonomy_image` filter.
* Admin settings page (Settings > OpenGraph) with three tabs: Settings, Tools, Diagnostics.
* Tools tab: Clear All Caches, Re-resolve All Images, Plugin Information.
* Diagnostics tab: statistics (cached count, coverage), URL tester with HTTP header display, paginated cached entries table with per-row clear action.
* WP-CLI command `wp og-fallback resolve` with `--all`, `--dry-run`, `--post-type` options.
* WP-CLI command `wp og-fallback clear-cache` with `--all` and `--dry-run` options.
* REST API: `POST /wp-json/robotstxt-og/v1/resolve/{post_id}` and `GET /wp-json/robotstxt-og/v1/status/{post_id}`.
* Developer filters: `robotstxt_og_external_image_enabled`, `robotstxt_og_external_image_timeout`, `robotstxt_og_taxonomy_image`, `robotstxt_og_enable_logging`.
* Auto-updater integration via Gitea (`robotstxt-updater.php`).
* Full internationalization (text domain: `robotstxt-og`, POT file included). Spanish (es_ES) translation included.
* `uninstall.php` with optional data deletion on plugin removal (opt-in, off by default).
* Multisite compatible with per-site configuration.
**Security**
* Nonce verification on all forms and state-changing GET actions.
* `manage_options` capability check on all admin pages, REST endpoints, and WP-CLI commands.
* Input sanitization via WordPress APIs (`sanitize_key`, `sanitize_text_field`, `esc_url_raw`, `filter_input`) throughout.
* Output escaping on all dynamic HTML (`esc_html`, `esc_attr`, `esc_url`, `absint`).
* Prepared statements (`$wpdb->prepare()`) for all raw database queries; no direct SQL writes.
* CSRF protection on all state-changing actions via `wp_nonce_url` / `wp_verify_nonce`.
* HTTP requests use `wp_remote_head()` with configurable timeout — no `file_get_contents` or `curl` directly.
**Compatibility**
* WordPress: 6.7 - 6.9
* PHP: 8.2 - 8.5
* WP-CLI: 2.x
* MariaDB: 10.6+
**Tests**
* PHP_CodeSniffer: 3.13.5
* WordPress Coding Standards: 3.3.0
* PHPCompatibility: 9.3.5

240
docs/FILTERS-HOOKS.md Normal file
View file

@ -0,0 +1,240 @@
# Filters & Hooks Reference
Developer reference for all filters and actions provided by the **OpenGraph (by ROBOTSTXT)** plugin.
## Table of Contents
- [Filters](#filters)
- [robotstxt_og_external_image_enabled](#robotstxt_og_external_image_enabled)
- [robotstxt_og_external_image_timeout](#robotstxt_og_external_image_timeout)
- [robotstxt_og_taxonomy_image](#robotstxt_og_taxonomy_image)
- [robotstxt_og_enable_logging](#robotstxt_og_enable_logging)
- [Actions](#actions)
- [SEO Plugin Integrations](#seo-plugin-integrations)
---
## Filters
### `robotstxt_og_external_image_enabled`
Controls whether the plugin attempts to resolve fallback images for external URLs (images hosted on a different domain than the WordPress site).
**Default:** `true`
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `$enabled` | `bool` | Whether external image resolution is enabled. |
| `$image_url` | `string` | The external image URL being evaluated. |
**Returns:** `bool`
**Example — disable external image resolution entirely:**
```php
add_filter( 'robotstxt_og_external_image_enabled', '__return_false' );
```
**Example — disable only for a specific CDN domain:**
```php
add_filter( 'robotstxt_og_external_image_enabled', function ( bool $enabled, string $image_url ): bool {
if ( str_contains( $image_url, 'cdn.example.com' ) ) {
return false;
}
return $enabled;
}, 10, 2 );
```
---
### `robotstxt_og_external_image_timeout`
Sets the HTTP request timeout (in seconds) used when verifying whether a fallback image URL exists via a HEAD request.
**Default:** `5` (seconds)
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `$timeout` | `int` | Timeout in seconds for the HEAD request. |
| `$url` | `string` | The image URL being tested. |
**Returns:** `int`
**Example — increase timeout for slow external servers:**
```php
add_filter( 'robotstxt_og_external_image_timeout', function ( int $timeout, string $url ): int {
if ( str_contains( $url, 'slow-cdn.example.com' ) ) {
return 15;
}
return $timeout;
}, 10, 2 );
```
**Example — set a global lower timeout for performance:**
```php
add_filter( 'robotstxt_og_external_image_timeout', function (): int {
return 3;
} );
```
---
### `robotstxt_og_taxonomy_image`
Provides a fallback OG image URL for taxonomy archive pages (categories, tags, custom taxonomies). By default, taxonomy archives do not have a featured image, so this filter is the primary way to supply one.
**Default:** `''` (empty string — no image)
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `$image_url` | `string` | Image URL to use. Empty string by default. |
| `$term_id` | `int` | The term ID of the current taxonomy archive. |
**Returns:** `string` A valid image URL, or empty string to skip.
**Example — use a custom field set on the term:**
```php
add_filter( 'robotstxt_og_taxonomy_image', function ( string $image_url, int $term_id ): string {
$custom_image_id = get_term_meta( $term_id, 'og_image_id', true );
if ( $custom_image_id ) {
$url = wp_get_attachment_url( (int) $custom_image_id );
return $url ? $url : $image_url;
}
return $image_url;
}, 10, 2 );
```
**Example — use a WooCommerce category thumbnail:**
```php
add_filter( 'robotstxt_og_taxonomy_image', function ( string $image_url, int $term_id ): string {
$thumbnail_id = get_term_meta( $term_id, 'thumbnail_id', true );
if ( $thumbnail_id ) {
$url = wp_get_attachment_url( (int) $thumbnail_id );
return $url ? $url : $image_url;
}
return $image_url;
}, 10, 2 );
```
---
### `robotstxt_og_enable_logging`
Enables or disables debug logging to `wp-content/debug.log`. When enabled, resolution events (cache hits, cache misses, format detection, HEAD request results) are written to the error log.
**Default:** `false`
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `$enabled` | `bool` | Whether debug logging is active. |
**Returns:** `bool`
**Note:** Requires `WP_DEBUG` and `WP_DEBUG_LOG` to be enabled in `wp-config.php` for output to appear in `debug.log`.
**Example — enable logging (e.g. during development, in `wp-config.php`):**
```php
// wp-config.php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
```
```php
// functions.php or a mu-plugin
add_filter( 'robotstxt_og_enable_logging', '__return_true' );
```
**Example — enable logging only for specific users:**
```php
add_filter( 'robotstxt_og_enable_logging', function ( bool $enabled ): bool {
return current_user_can( 'manage_options' ) ? true : $enabled;
} );
```
---
## Actions
The plugin does not currently expose custom action hooks. WordPress core hooks used internally include:
| Hook | Context | Purpose |
|------|---------|---------|
| `plugins_loaded` | Global | Loads text domain for translations. |
| `wp_head` | Frontend | Injects `og:image` meta tags (only when no SEO plugin is active). |
| `admin_menu` | Admin | Registers the Settings > OpenGraph settings page. |
| `admin_init` | Admin | Registers settings, handles cache clear/resolve actions. |
| `admin_enqueue_scripts` | Admin | Enqueues media uploader and admin CSS/JS. |
| `rest_api_init` | REST API | Registers the `robotstxt-og/v1` REST endpoints. |
| `updated_post_meta` | Global | Auto-clears fallback cache when `_thumbnail_id` changes. |
| `deleted_post_meta` | Global | Auto-clears fallback cache when `_thumbnail_id` is removed. |
---
## SEO Plugin Integrations
When a supported SEO plugin is detected, the plugin switches from direct `og:image` tag injection to filtering the SEO plugin's output. This prevents duplicate meta tags.
### Yoast SEO
**Filter:** `wpseo_opengraph_image`
When Yoast SEO is active (`WPSEO_VERSION` is defined), the plugin hooks into this filter to provide the resolved fallback image. The plugin only overrides the value if a valid fallback URL is resolved; otherwise it returns the original Yoast value unchanged.
### RankMath
**Filter:** `rank_math/opengraph/facebook/og_image`
When RankMath is active (`RankMath` class exists), the plugin hooks into this filter with the same logic as the Yoast integration.
### Adding Support for Other SEO Plugins
To integrate with another SEO plugin, hook into the plugin's OG image filter and call the resolver manually:
```php
add_filter( 'your_seo_plugin_og_image_filter', function ( string $image ) : string {
if ( ! is_singular() ) {
return $image;
}
$post_id = get_queried_object_id();
$resolver = Robotstxt_OG_Image_Fallback::get_instance()->get_resolver();
$fallback = $resolver->get_fallback_image( $post_id );
return ! empty( $fallback ) ? $fallback : $image;
} );
```
---
## Postmeta Keys (Internal Cache)
These postmeta keys are used internally for caching and should not be modified directly:
| Meta Key | Type | Description |
|----------|------|-------------|
| `_og_image_fallback_url` | `string` | Cached resolved fallback image URL for a post. |
These transient keys are used for negative caching (failed HEAD request results):
| Transient Key Pattern | TTL | Description |
|-----------------------|-----|-------------|
| `robotstxt_og_miss_{md5_of_url}` | 1 hour | Marks a URL as unreachable to prevent repeated requests. |

View file

@ -0,0 +1,183 @@
# Open Graph & Twitter Cards — Reference
This document covers the Open Graph (OG) and Twitter Cards meta tag specifications relevant to this plugin, including which tags are mandatory, recommended, optional, and which this plugin outputs automatically.
---
## Open Graph Protocol
Defined by Facebook/Meta. All OG tags use the `property` attribute.
### Core Tags (og: namespace)
| Property | Type | Required | Notes |
|---|---|---|---|
| `og:title` | string | **Required** | Title of the content. Used by all social crawlers. |
| `og:type` | string | **Required** | Content type: `website`, `article`, `video.movie`, etc. |
| `og:url` | URL | **Required** | Canonical URL of the page. |
| `og:description` | string | Recommended | Short description (24 sentences). Max ~300 chars. |
| `og:site_name` | string | Recommended | Name of the overall site (e.g. "My Blog"). |
| `og:locale` | string | Recommended | Locale in `language_TERRITORY` format (e.g. `es_ES`). |
| `og:image` | URL | **Required** (for cards) | Must be JPEG or PNG for social crawler compatibility. Min 200×200 px. Recommended 1200×630 px. |
| `og:image:secure_url` | URL | Optional | HTTPS version of `og:image`. Same value when the site is HTTPS-only. |
| `og:image:type` | MIME type | Recommended | MIME type of the image (`image/jpeg`, `image/png`). |
| `og:image:width` | integer | Recommended | Width in pixels. Avoids reflow in crawler previews. |
| `og:image:height` | integer | Recommended | Height in pixels. |
| `og:image:alt` | string | Recommended | Alt text for the image. Required for accessibility audits. |
### Article Tags (article: namespace)
Used when `og:type = article`. All are optional but recommended for news/blog content.
| Property | Type | Notes |
|---|---|---|
| `article:published_time` | ISO 8601 datetime | Publication date (`c` format in PHP: `get_the_date('c')`). |
| `article:modified_time` | ISO 8601 datetime | Last modification date. |
| `article:author` | URL | Profile page of the author (Facebook profile URL). Often omitted. |
| `article:section` | string | Primary category or section (e.g. "Technology"). |
| `article:tag` | string | Topic tags. Can be repeated once per tag. |
| `article:expiration_time` | ISO 8601 datetime | When the article expires (rarely used). |
### Video Tags (video: namespace)
For `og:type = video.movie`, `video.episode`, etc. Out of scope for this plugin.
---
## Twitter Cards
Defined by X (formerly Twitter). Tags use the `name` attribute (not `property`).
Twitter falls back to `og:*` tags if the corresponding `twitter:*` tag is absent — **except** `twitter:card`, which is always required.
### Card Types
| Value | Description |
|---|---|
| `summary` | Small square image (minimum 144×144 px). |
| `summary_large_image` | Large rectangular image (minimum 300×157 px, recommended 1200×628 px). Most common for blog/news content. |
| `app` | Promotes a mobile app. |
| `player` | Embeds a video/audio player. |
### Twitter Tags
| Name | Required | Falls back to | Notes |
|---|---|---|---|
| `twitter:card` | **Required** | — | Must always be present. Without it, no Twitter Card is shown. |
| `twitter:site` | Recommended | — | `@username` of the site's Twitter/X account. |
| `twitter:creator` | Optional | — | `@username` of the content author. |
| `twitter:title` | Recommended | `og:title` | Title of the content. |
| `twitter:description` | Recommended | `og:description` | Description. Max 200 chars. |
| `twitter:image` | Recommended | `og:image` | Must be JPEG, PNG, WebP, or GIF. Max 5 MB. |
| `twitter:image:alt` | Recommended | `og:image:alt` | Alt text for the image. Max 420 chars. |
---
## What This Plugin Outputs
### When no SEO plugin is active (direct injection)
The plugin outputs a **complete** set of OG and Twitter Card tags via `wp_head` (priority 5).
#### Open Graph tags
```html
<!-- Core -->
<meta property="og:title" content="..." />
<meta property="og:type" content="article" /> <!-- or "website" -->
<meta property="og:url" content="..." />
<meta property="og:description" content="..." /> <!-- when available -->
<meta property="og:site_name" content="..." />
<meta property="og:locale" content="es_ES" />
<!-- Image (when a compatible image is resolved) -->
<meta property="og:image" content="https://...jpg" />
<meta property="og:image:secure_url" content="https://...jpg" /> <!-- HTTPS only -->
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content="..." /> <!-- when set in media library -->
<!-- Article-specific (og:type = article only) -->
<meta property="article:published_time" content="2026-02-18T00:00:00+00:00" />
<meta property="article:modified_time" content="2026-02-18T00:00:00+00:00" />
<meta property="article:section" content="Technology" /> <!-- primary category -->
<meta property="article:tag" content="WordPress" /> <!-- repeated per tag -->
```
#### Twitter Card tags
```html
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@example" /> <!-- when configured in Settings -->
<meta name="twitter:image" content="https://...jpg" /> <!-- when image available -->
```
> Twitter falls back to `og:title`, `og:description`, and `og:image` automatically, so those tags are not duplicated.
### When Yoast SEO or RankMath is active
The plugin acts as a **corrector only**: it filters the image URL via the SEO plugin's filter hook, converting incompatible formats (AVIF/WebP) to JPEG/PNG. All other OG/Twitter tags are managed by the SEO plugin.
---
## og:type Values Reference
| Value | When to use |
|---|---|
| `website` | Default for homepages and most pages. |
| `article` | Blog posts, news articles. This plugin uses this for `is_singular('post')`. |
| `profile` | User profile pages. |
| `video.movie` | Movie pages. |
| `video.episode` | TV episode pages. |
| `music.song` | Song pages. |
| `music.album` | Album pages. |
---
## Per-Post Overrides (Editor Meta Box)
The plugin adds an **"Open Graph / Social Media"** meta box to all post editors, allowing per-post overrides of:
| Field | OG Tag | Fallback |
|---|---|---|
| Custom Title | `og:title` | Post title (`get_the_title()`) |
| Custom Description | `og:description` | Post excerpt, or empty |
These overrides are stored as post meta:
- `_og_title` — custom OG title
- `_og_description` — custom OG description
---
## Context → og:type Mapping (this plugin)
| WordPress context | `og:type` |
|---|---|
| `is_singular('post')` | `article` |
| `is_singular('page')` | `website` |
| `is_singular(other)` | `website` |
| `is_front_page()` / `is_home()` | `website` |
| `is_tax()` / `is_category()` / `is_tag()` | `website` |
---
## Image Compatibility
Social crawlers (Facebook, X, LinkedIn, WhatsApp, Telegram) generally require:
- Format: **JPEG or PNG** (WebP partial support; AVIF not supported)
- Minimum size: 200×200 px (Facebook requires 200×200 for `summary`)
- Recommended: 1200×630 px for `summary_large_image`
- Max file size: 8 MB (Facebook), 5 MB (Twitter)
This plugin's core function is to detect when a featured image is in an incompatible format (AVIF, WebP) and automatically serve a JPEG/PNG alternative via fallback resolution.
---
## Validation Tools
- **Facebook**: [Sharing Debugger](https://developers.facebook.com/tools/debug/)
- **Twitter/X**: [Card Validator](https://cards-dev.twitter.com/validator)
- **LinkedIn**: [Post Inspector](https://www.linkedin.com/post-inspector/)
- **OpenGraph.xyz**: [OpenGraph preview](https://www.opengraph.xyz/)
- **Metatags.io**: [Meta tag preview](https://metatags.io/)

103
docs/WP-CLI.md Normal file
View file

@ -0,0 +1,103 @@
# WP-CLI Command Reference
The plugin registers WP-CLI commands under the `og-fallback` namespace.
## Commands
### `wp og-fallback resolve`
Resolves (or re-resolves) the OG fallback image for one or all posts with featured images.
**Usage:**
```bash
wp og-fallback resolve [<post_id>] [--all] [--dry-run] [--post-type=<type>]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `<post_id>` | (optional) Single post ID to resolve. |
| `--all` | Process all posts that have a featured image. |
| `--dry-run` | Preview without making any changes. |
| `--post-type=<type>` | Limit `--all` to a specific post type (default: `any`). |
**Examples:**
```bash
# Resolve fallback for a single post
wp og-fallback resolve 123
# Preview for a single post without saving
wp og-fallback resolve 123 --dry-run
# Re-resolve all posts (clears cache first)
wp og-fallback resolve --all
# Re-resolve only 'product' post type posts
wp og-fallback resolve --all --post-type=product
# Dry-run all (shows count, no changes)
wp og-fallback resolve --all --dry-run
```
**Output examples:**
```
Success: Post 123 resolved to https://example.com/uploads/image.jpg
Warning: Post 456: no compatible image found.
Success: Resolved 47 posts. Failed: 2.
Found 49 posts with featured images. (dry-run, no changes made)
```
---
### `wp og-fallback clear-cache`
Deletes cached fallback URLs from postmeta.
**Usage:**
```bash
wp og-fallback clear-cache [<post_id>] [--all] [--dry-run]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `<post_id>` | (optional) Single post ID to clear cache for. |
| `--all` | Clear all cached fallback URLs. |
| `--dry-run` | Preview without making any changes. |
**Examples:**
```bash
# Clear cache for a single post
wp og-fallback clear-cache 123
# Clear all cached fallback URLs
wp og-fallback clear-cache --all
# Preview how many entries would be cleared
wp og-fallback clear-cache --all --dry-run
```
**Output examples:**
```
Success: Cleared cached fallback URL for post 123.
Warning: Post 456 has no cached fallback URL.
Success: Cleared 47 cached fallback URLs.
Found 47 cached fallback URLs. (dry-run, no changes made)
```
---
## Notes
- Both commands require WP-CLI 2.x.
- No capability check is enforced at the CLI level (WP-CLI access implies server-level trust).
- The `--all` flag with `resolve` is equivalent to clicking **Re-resolve All Images** in the admin Tools tab.
- The `--all` flag with `clear-cache` is equivalent to clicking **Clear All Cached URLs** in the admin Tools tab.

View file

@ -0,0 +1,337 @@
<?php
/**
* WP-CLI Commands Class
*
* Provides WP-CLI commands for managing OG image fallbacks.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_CLI
*
* WP-CLI commands for OpenGraph (by ROBOTSTXT) plugin.
*
* ## EXAMPLES
*
* # Resolve fallback images for all posts
* $ wp og-fallback resolve --all
* Success: Resolved 45 posts. Failed: 2.
*
* # Resolve a single post
* $ wp og-fallback resolve 42
* Success: Post 42 resolved to https://example.com/image.jpg
*
* # Clear all cached fallback URLs
* $ wp og-fallback clear-cache --all
* Success: Cleared 45 cached fallback URLs.
*
* # Preview without making changes
* $ wp og-fallback resolve --all --dry-run
* Found 47 posts with featured images. (dry-run, no changes made)
*
* @since 1.0.0
*/
class Robotstxt_OG_CLI extends WP_CLI_Command {
/**
* Image resolver instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Resolver
*/
private Robotstxt_OG_Image_Resolver $resolver;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
*/
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
$this->resolver = $resolver;
}
/**
* Resolve fallback images for posts.
*
* Clears cached fallback URL and re-resolves it for the specified post(s).
* Use --all to process all posts with featured images.
*
* ## OPTIONS
*
* [<post_id>]
* : The ID of a single post to resolve.
*
* [--all]
* : Resolve fallback images for all posts with featured images.
*
* [--dry-run]
* : Preview what would be done without making changes.
*
* [--post-type=<type>]
* : Limit resolution to a specific post type. Default: any.
*
* ## EXAMPLES
*
* wp og-fallback resolve --all
* wp og-fallback resolve --all --dry-run
* wp og-fallback resolve 42
* wp og-fallback resolve --all --post-type=post
*
* @since 1.0.0
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*/
public function resolve( array $args, array $assoc_args ): void {
$all = isset( $assoc_args['all'] );
$dry_run = isset( $assoc_args['dry-run'] );
$post_type = isset( $assoc_args['post-type'] ) ? $assoc_args['post-type'] : 'any';
if ( $all ) {
$this->resolve_all( $dry_run, $post_type );
return;
}
if ( ! empty( $args[0] ) ) {
$post_id = absint( $args[0] );
$this->resolve_single( $post_id, $dry_run );
return;
}
WP_CLI::error( 'Please specify a post ID or use --all flag.' );
}
/**
* Clear cached fallback URLs.
*
* ## OPTIONS
*
* [<post_id>]
* : The ID of a single post to clear cache for.
*
* [--all]
* : Clear all cached fallback URLs.
*
* [--dry-run]
* : Preview what would be done without making changes.
*
* ## EXAMPLES
*
* wp og-fallback clear-cache --all
* wp og-fallback clear-cache 42
* wp og-fallback clear-cache --all --dry-run
*
* @since 1.0.0
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*/
public function clear_cache( array $args, array $assoc_args ): void {
$all = isset( $assoc_args['all'] );
$dry_run = isset( $assoc_args['dry-run'] );
if ( $all ) {
$this->clear_all_caches( $dry_run );
return;
}
if ( ! empty( $args[0] ) ) {
$post_id = absint( $args[0] );
$this->clear_single_cache( $post_id, $dry_run );
return;
}
WP_CLI::error( 'Please specify a post ID or use --all flag.' );
}
/**
* Resolve fallback images for all posts.
*
* @since 1.0.0
*
* @param bool $dry_run Whether to run without making changes.
* @param string $post_type Post type to limit to.
* @return void
*/
private function resolve_all( bool $dry_run, string $post_type ): void {
$posts = get_posts(
array(
'post_type' => $post_type,
'post_status' => 'any',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_thumbnail_id',
'compare' => 'EXISTS',
),
),
'fields' => 'ids',
)
);
$count = count( $posts );
if ( $dry_run ) {
WP_CLI::line(
/* translators: %d: number of posts found */
sprintf( __( 'Found %d posts with featured images. (dry-run, no changes made)', 'robotstxt-og' ), $count )
);
return;
}
if ( 0 === $count ) {
WP_CLI::warning( __( 'No posts with featured images found.', 'robotstxt-og' ) );
return;
}
$success = 0;
$failed = 0;
$progress = WP_CLI\Utils\make_progress_bar(
/* translators: %d: number of posts to process */
sprintf( __( 'Resolving %d posts', 'robotstxt-og' ), $count ),
$count
);
foreach ( $posts as $post_id ) {
$this->resolver->clear_cache( $post_id );
$url = $this->resolver->resolve_image( $post_id );
if ( ! empty( $url ) ) {
++$success;
} else {
++$failed;
}
$progress->tick();
}
$progress->finish();
WP_CLI::success(
/* translators: 1: successful count, 2: failed count */
sprintf( __( 'Resolved %1$d posts. Failed: %2$d.', 'robotstxt-og' ), $success, $failed )
);
}
/**
* Resolve fallback image for a single post.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @param bool $dry_run Whether to run without making changes.
* @return void
*/
private function resolve_single( int $post_id, bool $dry_run ): void {
$post = get_post( $post_id );
if ( ! $post ) {
/* translators: %d: post ID */
WP_CLI::error( sprintf( __( 'Post %d not found.', 'robotstxt-og' ), $post_id ) );
return;
}
if ( $dry_run ) {
$thumb_id = get_post_thumbnail_id( $post_id );
$thumb = $thumb_id ? wp_get_attachment_url( $thumb_id ) : '';
/* translators: 1: post ID, 2: image URL */
WP_CLI::line( sprintf( __( 'Post %1$d has featured image: %2$s (dry-run, no changes made)', 'robotstxt-og' ), $post_id, ! empty( $thumb ) ? $thumb : 'none' ) );
return;
}
$this->resolver->clear_cache( $post_id );
$url = $this->resolver->resolve_image( $post_id );
if ( ! empty( $url ) ) {
/* translators: 1: post ID, 2: resolved URL */
WP_CLI::success( sprintf( __( 'Post %1$d resolved to %2$s', 'robotstxt-og' ), $post_id, $url ) );
} else {
/* translators: %d: post ID */
WP_CLI::warning( sprintf( __( 'Post %d: no compatible image found.', 'robotstxt-og' ), $post_id ) );
}
}
/**
* Clear all cached fallback URLs.
*
* @since 1.0.0
*
* @param bool $dry_run Whether to run without making changes.
* @return void
*/
private function clear_all_caches( bool $dry_run ): void {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// WP-CLI single-invocation context; no persistent cache layer is appropriate here.
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_image_fallback_url'
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
if ( $dry_run ) {
/* translators: %d: number of cache entries found */
WP_CLI::line( sprintf( __( 'Found %d cached fallback URLs. (dry-run, no changes made)', 'robotstxt-og' ), $count ) );
return;
}
if ( 0 === $count ) {
WP_CLI::warning( __( 'No cached fallback URLs found.', 'robotstxt-og' ) );
return;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Bulk delete of all cached postmeta rows — no WP API equivalent for this operation.
$deleted = $wpdb->delete(
$wpdb->postmeta,
array( 'meta_key' => '_og_image_fallback_url' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' )
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
/* translators: %d: number of cleared entries */
WP_CLI::success( sprintf( __( 'Cleared %d cached fallback URLs.', 'robotstxt-og' ), (int) $deleted ) );
}
/**
* Clear cached fallback URL for a single post.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @param bool $dry_run Whether to run without making changes.
* @return void
*/
private function clear_single_cache( int $post_id, bool $dry_run ): void {
$cached = get_post_meta( $post_id, '_og_image_fallback_url', true );
if ( $dry_run ) {
/* translators: 1: post ID, 2: cached URL or 'none' */
WP_CLI::line( sprintf( __( 'Post %1$d cached URL: %2$s (dry-run, no changes made)', 'robotstxt-og' ), $post_id, ! empty( $cached ) ? $cached : 'none' ) );
return;
}
if ( empty( $cached ) ) {
/* translators: %d: post ID */
WP_CLI::warning( sprintf( __( 'Post %d has no cached fallback URL.', 'robotstxt-og' ), $post_id ) );
return;
}
$this->resolver->clear_cache( $post_id );
/* translators: %d: post ID */
WP_CLI::success( sprintf( __( 'Cleared cached fallback URL for post %d.', 'robotstxt-og' ), $post_id ) );
}
}

View file

@ -0,0 +1,247 @@
<?php
/**
* Main Plugin Class
*
* Orchestrates plugin initialization and component integration.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Image_Fallback
*
* Main plugin class using singleton pattern.
*
* @since 1.0.0
*/
class Robotstxt_OG_Image_Fallback {
/**
* Single instance of the class.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Fallback|null
*/
private static ?Robotstxt_OG_Image_Fallback $instance = null;
/**
* Image resolver instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Resolver
*/
private Robotstxt_OG_Image_Resolver $resolver;
/**
* OG tags generator instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Tags
*/
private Robotstxt_OG_Tags $tags;
/**
* Admin settings instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Admin_Settings|null
*/
private ?Robotstxt_OG_Admin_Settings $admin_settings = null;
/**
* REST API instance.
*
* @since 1.0.0
* @var Robotstxt_OG_REST_API
*/
private Robotstxt_OG_REST_API $rest_api;
/**
* Meta box instance.
*
* @since 1.2.0
* @var Robotstxt_OG_Meta_Box
*/
private Robotstxt_OG_Meta_Box $meta_box;
/**
* Get singleton instance.
*
* @since 1.0.0
*
* @return Robotstxt_OG_Image_Fallback
*/
public static function get_instance(): Robotstxt_OG_Image_Fallback {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*
* Private to enforce singleton pattern.
*
* @since 1.0.0
*/
private function __construct() {
// Constructor is private.
}
/**
* Initialize the plugin.
*
* @since 1.0.0
*
* @return void
*/
public function init(): void {
// Load dependencies.
$this->load_dependencies();
// Initialize components.
$this->resolver = new Robotstxt_OG_Image_Resolver();
$this->tags = new Robotstxt_OG_Tags( $this->resolver );
// Initialize OG tags generator.
$this->tags->init();
// Initialize REST API.
$this->rest_api = new Robotstxt_OG_REST_API( $this->resolver );
$this->rest_api->init();
// Initialize meta box (registers post meta for REST + editor UI).
$this->meta_box = new Robotstxt_OG_Meta_Box();
$this->meta_box->init();
// Initialize admin settings if in admin context.
if ( is_admin() ) {
$this->admin_settings = new Robotstxt_OG_Admin_Settings( $this->resolver );
$this->admin_settings->init();
}
// Register WP-CLI commands.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'og-fallback', new Robotstxt_OG_CLI( $this->resolver ) );
}
// Auto-regenerate cache when featured image changes.
add_action( 'updated_post_meta', array( $this, 'handle_thumbnail_change' ), 10, 4 );
add_action( 'deleted_post_meta', array( $this, 'handle_thumbnail_change' ), 10, 4 );
// Register activation and deactivation hooks.
register_activation_hook( ROBOTSTXT_OG_PATH . 'robotstxt-og.php', array( $this, 'activate' ) );
register_deactivation_hook( ROBOTSTXT_OG_PATH . 'robotstxt-og.php', array( $this, 'deactivate' ) );
}
/**
* Load plugin dependencies.
*
* @since 1.0.0
*
* @return void
*/
private function load_dependencies(): void {
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-image-resolver.php';
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-tags.php';
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-rest-api.php';
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-meta-box.php';
// Load admin class if in admin context.
if ( is_admin() ) {
require_once ROBOTSTXT_OG_PATH . 'admin/class-robotstxt-og-admin-settings.php';
}
// Load WP-CLI class if running in CLI context.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-cli.php';
}
}
/**
* Activation hook callback.
*
* Runs when the plugin is activated.
*
* @since 1.0.0
*
* @return void
*/
public function activate(): void {
// Set default options if they don't exist.
if ( false === get_option( 'robotstxt_og_fallback_image' ) ) {
add_option( 'robotstxt_og_fallback_image', '' );
}
if ( false === get_option( 'robotstxt_og_delete_data_on_uninstall' ) ) {
add_option( 'robotstxt_og_delete_data_on_uninstall', false );
}
// Flush rewrite rules (if needed in future).
flush_rewrite_rules();
}
/**
* Deactivation hook callback.
*
* Runs when the plugin is deactivated.
*
* @since 1.0.0
*
* @return void
*/
public function deactivate(): void {
// Flush rewrite rules (if needed in future).
flush_rewrite_rules();
}
/**
* Clear fallback cache when a post's featured image is changed or removed.
*
* Hooked to `updated_post_meta` and `deleted_post_meta` for `_thumbnail_id`.
*
* @since 1.0.0
*
* @param int $meta_id ID of the meta data entry.
* @param int $post_id Post ID.
* @param string $meta_key Meta key being updated.
* @param mixed $meta_value New meta value (unused).
* @return void
*/
public function handle_thumbnail_change( int $meta_id, int $post_id, string $meta_key, $meta_value = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( '_thumbnail_id' !== $meta_key ) {
return;
}
$this->resolver->clear_cache( $post_id );
}
/**
* Get image resolver instance.
*
* @since 1.0.0
*
* @return Robotstxt_OG_Image_Resolver
*/
public function get_resolver(): Robotstxt_OG_Image_Resolver {
return $this->resolver;
}
/**
* Get OG tags generator instance.
*
* @since 1.0.0
*
* @return Robotstxt_OG_Tags
*/
public function get_tags(): Robotstxt_OG_Tags {
return $this->tags;
}
}

View file

@ -0,0 +1,583 @@
<?php
/**
* Image Resolver Class
*
* Handles detection and resolution of compatible fallback images for Open Graph tags.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Image_Resolver
*
* Resolves compatible image formats for social media crawlers.
*
* @since 1.0.0
*/
class Robotstxt_OG_Image_Resolver {
/**
* Transient prefix for negative cache entries.
*
* @since 1.0.0
* @var string
*/
const NEGATIVE_CACHE_PREFIX = 'robotstxt_og_miss_';
/**
* How long to cache negative results (URL not found), in seconds.
*
* @since 1.0.0
* @var int
*/
const NEGATIVE_CACHE_TTL = HOUR_IN_SECONDS;
/**
* Get fallback image URL for a post.
*
* Checks cache first, then resolves if needed.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @return string Image URL or empty string if not found.
*/
public function get_fallback_image( int $post_id ): string {
// Check postmeta cache first.
$cached_url = get_post_meta( $post_id, '_og_image_fallback_url', true );
if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
$this->log(
'cache_hit',
array(
'post_id' => $post_id,
'url' => $cached_url,
)
);
return esc_url_raw( $cached_url );
}
$this->log( 'cache_miss', array( 'post_id' => $post_id ) );
// No valid cache, resolve image.
return $this->resolve_image( $post_id );
}
/**
* Resolve fallback image for a post.
*
* Detects featured image format and finds compatible alternative if needed.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @return string Resolved image URL or empty string.
*/
public function resolve_image( int $post_id ): string {
// Get featured image ID.
$image_id = get_post_thumbnail_id( $post_id );
if ( empty( $image_id ) ) {
// No featured image, check for global fallback.
$this->log( 'no_featured_image', array( 'post_id' => $post_id ) );
return $this->get_global_fallback();
}
// Get image URL.
$image_url = wp_get_attachment_url( $image_id );
if ( empty( $image_url ) ) {
$this->log(
'no_attachment_url',
array(
'post_id' => $post_id,
'image_id' => $image_id,
)
);
return $this->get_global_fallback();
}
// Detect file extension.
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
// If already a compatible format, save and return.
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
$this->log(
'compatible_format',
array(
'post_id' => $post_id,
'url' => $image_url,
)
);
$this->save_cache( $post_id, $image_url );
return esc_url_raw( $image_url );
}
// Modern format detected (webp, avif, etc.), find alternative.
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
$this->log(
'modern_format_detected',
array(
'post_id' => $post_id,
'extension' => $extension,
'url' => $image_url,
)
);
$alternative = $this->find_compatible_alternative( $image_url );
if ( ! empty( $alternative ) ) {
$this->log(
'alternative_found',
array(
'post_id' => $post_id,
'original' => $image_url,
'alternative' => $alternative,
)
);
$this->save_cache( $post_id, $alternative );
return esc_url_raw( $alternative );
}
$this->log(
'no_alternative_found',
array(
'post_id' => $post_id,
'url' => $image_url,
)
);
}
// No alternative found, return original (don't cache to allow retry on next request).
return esc_url_raw( $image_url );
}
/**
* Find compatible image alternative by checking .jpg and .png versions.
*
* Uses HTTP HEAD requests to verify file existence.
* Respects the `robotstxt_og_external_image_enabled` filter for external images.
*
* @since 1.0.0
*
* @param string $image_url Original image URL.
* @return string Compatible image URL or empty string if not found.
*/
public function find_compatible_alternative( string $image_url ): string {
// Check if external images are allowed.
if ( $this->is_external_url( $image_url ) ) {
/**
* Filter whether to attempt resolution for external images.
*
* @since 1.0.0
*
* @param bool $enabled Whether external image resolution is enabled.
* @param string $image_url The external image URL being resolved.
*/
$enabled = apply_filters( 'robotstxt_og_external_image_enabled', true, $image_url );
if ( ! $enabled ) {
$this->log( 'external_image_skipped', array( 'url' => $image_url ) );
return '';
}
}
// Strip query string for URL manipulation.
$clean_url = strtok( $image_url, '?' );
// Parse URL and get base path without extension.
$path_info = pathinfo( wp_parse_url( $clean_url, PHP_URL_PATH ) );
$filename = $path_info['filename'] ?? '';
$dir = $path_info['dirname'] ?? '';
if ( empty( $filename ) ) {
return '';
}
// Reconstruct base URL.
$parsed = wp_parse_url( $clean_url );
$base_url = ( $parsed['scheme'] ?? 'https' ) . '://' . ( $parsed['host'] ?? '' );
if ( ! empty( $parsed['port'] ) ) {
$base_url .= ':' . $parsed['port'];
}
$base_url .= trailingslashit( $dir ) . $filename;
// Try .jpg first, then .png.
$alternatives = array( '.jpg', '.png', '.jpeg' );
foreach ( $alternatives as $ext ) {
$test_url = $base_url . $ext;
if ( $this->url_exists( $test_url ) ) {
return $test_url;
}
}
return '';
}
/**
* Check if a URL exists using HTTP HEAD request.
*
* Uses negative caching to avoid repeated requests for non-existent URLs.
*
* @since 1.0.0
*
* @param string $url URL to check.
* @return bool True if URL returns 200-299 status code.
*/
private function url_exists( string $url ): bool {
// Check negative cache to avoid repeated failed requests.
$cache_key = self::NEGATIVE_CACHE_PREFIX . md5( $url );
if ( false !== get_transient( $cache_key ) ) {
$this->log( 'negative_cache_hit', array( 'url' => $url ) );
return false;
}
/**
* Filter the timeout in seconds for external image HEAD requests.
*
* @since 1.0.0
*
* @param int $timeout The timeout in seconds.
* @param string $url The URL being checked.
*/
$timeout = (int) apply_filters( 'robotstxt_og_external_image_timeout', 5, $url );
// Use WordPress HTTP API.
$response = wp_remote_head(
$url,
array(
'timeout' => max( 1, $timeout ),
'redirection' => 5,
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . get_bloginfo( 'url' ),
)
);
// Handle WP_Error (network failure, timeout, etc.).
if ( is_wp_error( $response ) ) {
$this->log(
'head_request_error',
array(
'url' => $url,
'error' => $response->get_error_message(),
)
);
set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL );
return false;
}
// Check response code.
$code = (int) wp_remote_retrieve_response_code( $response );
$exists = $code >= 200 && $code < 300;
if ( ! $exists ) {
$this->log(
'head_request_failed',
array(
'url' => $url,
'code' => $code,
)
);
set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL );
} else {
$this->log(
'head_request_success',
array(
'url' => $url,
'code' => $code,
)
);
}
return $exists;
}
/**
* Check if a URL is external (hosted on a different domain).
*
* @since 1.0.0
*
* @param string $url URL to check.
* @return bool True if the URL is external.
*/
private function is_external_url( string $url ): bool {
$site_host = wp_parse_url( get_bloginfo( 'url' ), PHP_URL_HOST );
$image_host = wp_parse_url( $url, PHP_URL_HOST );
if ( empty( $site_host ) || empty( $image_host ) ) {
return false;
}
return strtolower( $site_host ) !== strtolower( $image_host );
}
/**
* Get global fallback image URL from settings.
*
* If the stored image is in an incompatible format (AVIF, WebP, etc.),
* attempts to resolve a JPEG/PNG alternative. Returns empty string if
* no compatible image is found.
*
* @since 1.0.0
*
* @return string Compatible image URL or empty string.
*/
private function get_global_fallback(): string {
$fallback_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
if ( empty( $fallback_url ) || ! $this->is_valid_url( $fallback_url ) ) {
return '';
}
return esc_url_raw( $fallback_url );
}
/**
* Get the configured homepage image URL.
*
* If the stored image is in an incompatible format (AVIF, WebP, etc.),
* attempts to resolve a JPEG/PNG alternative before falling back to
* the global fallback image.
*
* @since 1.1.0
*
* @return string Compatible image URL or empty string.
*/
public function get_homepage_image(): string {
$image_url = (string) get_option( 'robotstxt_og_homepage_image', '' );
if ( ! empty( $image_url ) && $this->is_valid_url( $image_url ) ) {
return esc_url_raw( $image_url );
}
// Fall back to global fallback image.
return $this->get_global_fallback();
}
/**
* Ensure an image URL is in a format compatible with social media crawlers.
*
* Returns the URL unchanged for JPEG/PNG. For incompatible formats
* (AVIF, WebP, GIF, SVG, BMP, TIFF), attempts to find a JPEG/PNG
* alternative via find_compatible_alternative(). Returns empty string
* if no compatible version can be found.
*
* @since 1.2.0
*
* @param string $image_url Image URL to check.
* @return string Compatible image URL or empty string.
*/
public function ensure_compatible_format( string $image_url ): string {
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
// Already a compatible format.
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
return esc_url_raw( $image_url );
}
// Incompatible modern format — try to find a JPEG/PNG alternative.
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
$this->log(
'fallback_image_incompatible_format',
array(
'url' => $image_url,
'extension' => $extension,
)
);
$alternative = $this->find_compatible_alternative( $image_url );
if ( ! empty( $alternative ) ) {
$this->log(
'fallback_image_alternative_found',
array(
'original' => $image_url,
'alternative' => $alternative,
)
);
return esc_url_raw( $alternative );
}
$this->log( 'fallback_image_no_alternative', array( 'url' => $image_url ) );
// No compatible alternative found — discard this image.
return '';
}
// Unknown extension — return as-is and let the caller decide.
return esc_url_raw( $image_url );
}
/**
* Save resolved URL to postmeta cache.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @param string $url Resolved image URL.
* @return void
*/
private function save_cache( int $post_id, string $url ): void {
update_post_meta( $post_id, '_og_image_fallback_url', esc_url_raw( $url ) );
}
/**
* Clear cached fallback URL for a post.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @return void
*/
public function clear_cache( int $post_id ): void {
delete_post_meta( $post_id, '_og_image_fallback_url' );
}
/**
* Get fallback image URL for a taxonomy term.
*
* Checks term meta cache first, then looks for a term image set by
* supported plugins (e.g., Yoast SEO term images, custom term meta).
*
* @since 1.0.0
*
* @param int $term_id Term ID.
* @return string Image URL or empty string if not found.
*/
public function get_taxonomy_fallback_image( int $term_id ): string {
// Check term meta cache first.
$cached_url = get_term_meta( $term_id, '_og_image_fallback_url', true );
if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
$this->log(
'taxonomy_cache_hit',
array(
'term_id' => $term_id,
'url' => $cached_url,
)
);
return esc_url_raw( $cached_url );
}
$this->log( 'taxonomy_cache_miss', array( 'term_id' => $term_id ) );
return $this->resolve_taxonomy_image( $term_id );
}
/**
* Resolve fallback image for a taxonomy term.
*
* Checks for term images set by Yoast SEO or other plugins via a filter.
*
* @since 1.0.0
*
* @param int $term_id Term ID.
* @return string Resolved image URL or empty string.
*/
public function resolve_taxonomy_image( int $term_id ): string {
/**
* Filter the image URL for a taxonomy term.
*
* Use this filter to integrate with plugins that assign images to
* taxonomy terms (e.g., custom term meta, theme functions).
*
* @since 1.0.0
*
* @param string $image_url The image URL. Empty string if none.
* @param int $term_id The term ID being resolved.
*/
$image_url = (string) apply_filters( 'robotstxt_og_taxonomy_image', '', $term_id );
if ( empty( $image_url ) || ! $this->is_valid_url( $image_url ) ) {
return $this->get_global_fallback();
}
// Detect file extension and resolve if needed.
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $image_url ) );
return esc_url_raw( $image_url );
}
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
$alternative = $this->find_compatible_alternative( $image_url );
if ( ! empty( $alternative ) ) {
update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $alternative ) );
return esc_url_raw( $alternative );
}
}
return esc_url_raw( $image_url );
}
/**
* Clear cached fallback URL for a taxonomy term.
*
* @since 1.0.0
*
* @param int $term_id Term ID.
* @return void
*/
public function clear_taxonomy_cache( int $term_id ): void {
delete_term_meta( $term_id, '_og_image_fallback_url' );
}
/**
* Validate if a string is a valid URL.
*
* @since 1.0.0
*
* @param string $url URL to validate.
* @return bool True if valid URL.
*/
private function is_valid_url( string $url ): bool {
return false !== filter_var( $url, FILTER_VALIDATE_URL );
}
/**
* Log a resolution event if logging is enabled.
*
* Uses the `robotstxt_og_enable_logging` filter to toggle logging.
*
* @since 1.0.0
*
* @param string $event Event name (e.g., 'cache_hit', 'head_request_error').
* @param array $context Additional context data.
* @return void
*/
private function log( string $event, array $context = array() ): void {
/**
* Filter whether to enable debug logging for image resolution.
*
* @since 1.0.0
*
* @param bool $enabled Whether logging is enabled. Default false.
*/
if ( ! apply_filters( 'robotstxt_og_enable_logging', false ) ) {
return;
}
$message = sprintf(
'[robotstxt-og] %s | %s',
$event,
wp_json_encode( $context )
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $message );
}
}

View file

@ -0,0 +1,219 @@
<?php
/**
* Meta Box Class
*
* Provides per-post Open Graph and social media customization fields
* in the WordPress post editor.
*
* @package ROBOTSTXT_OG
* @since 1.2.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Meta_Box
*
* Registers and handles the Open Graph meta box in the post editor,
* and registers the underlying post meta for REST API access.
*
* @since 1.2.0
*/
class Robotstxt_OG_Meta_Box {
/**
* Initialize hooks.
*
* @since 1.2.0
*
* @return void
*/
public function init(): void {
// Register post meta for REST API access (block editor).
add_action( 'init', array( $this, 'register_post_meta' ) );
// Meta box UI is admin-only.
if ( is_admin() ) {
add_action( 'add_meta_boxes', array( $this, 'register_meta_box' ) );
add_action( 'save_post', array( $this, 'save_meta_box' ), 10, 2 );
}
}
/**
* Register custom post meta for all public post types.
*
* Exposes _og_title and _og_description via the REST API so the
* block editor can read and write them via useEntityProp.
*
* @since 1.2.0
*
* @return void
*/
public function register_post_meta(): void {
$post_types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $post_types as $post_type ) {
register_post_meta(
$post_type,
'_og_title',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
register_post_meta(
$post_type,
'_og_description',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_textarea_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
}
}
/**
* Register the meta box for all public post types.
*
* @since 1.2.0
*
* @return void
*/
public function register_meta_box(): void {
$post_types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $post_types as $post_type ) {
add_meta_box(
'robotstxt-og-meta-box',
__( 'Open Graph / Social Media', 'robotstxt-og' ),
array( $this, 'render_meta_box' ),
$post_type,
'normal',
'default'
);
}
}
/**
* Render the meta box HTML.
*
* @since 1.2.0
*
* @param WP_Post $post The current post object.
* @return void
*/
public function render_meta_box( WP_Post $post ): void {
wp_nonce_field( 'robotstxt_og_meta_box', 'robotstxt_og_meta_box_nonce' );
$og_title = (string) get_post_meta( $post->ID, '_og_title', true );
$og_description = (string) get_post_meta( $post->ID, '_og_description', true );
?>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row">
<label for="robotstxt_og_title">
<?php esc_html_e( 'Custom Title', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<input
type="text"
id="robotstxt_og_title"
name="robotstxt_og_title"
value="<?php echo esc_attr( $og_title ); ?>"
class="large-text"
/>
<p class="description">
<?php esc_html_e( 'Overrides the default og:title for this post. Leave blank to use the post title automatically.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="robotstxt_og_description">
<?php esc_html_e( 'Custom Description', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<textarea
id="robotstxt_og_description"
name="robotstxt_og_description"
class="large-text"
rows="3"
><?php echo esc_textarea( $og_description ); ?></textarea>
<p class="description">
<?php esc_html_e( 'Overrides the default og:description for this post. Leave blank to use the excerpt automatically.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
</tbody>
</table>
<?php
}
/**
* Save meta box values on post save.
*
* @since 1.2.0
*
* @param int $post_id The post ID.
* @param WP_Post $post The post object.
* @return void
*/
public function save_meta_box( int $post_id, WP_Post $post ): void {
// Skip autosaves and revisions.
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_POST, 'robotstxt_og_meta_box_nonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_meta_box' ) ) {
return;
}
// Check permissions.
$post_type_obj = get_post_type_object( $post->post_type );
if ( ! $post_type_obj || ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) {
return;
}
// Save og:title.
$og_title_raw = filter_input( INPUT_POST, 'robotstxt_og_title', FILTER_SANITIZE_SPECIAL_CHARS );
$og_title = $og_title_raw ? sanitize_text_field( wp_unslash( $og_title_raw ) ) : '';
if ( empty( $og_title ) ) {
delete_post_meta( $post_id, '_og_title' );
} else {
update_post_meta( $post_id, '_og_title', $og_title );
}
// Save og:description.
$og_desc_raw = filter_input( INPUT_POST, 'robotstxt_og_description', FILTER_UNSAFE_RAW );
$og_desc = $og_desc_raw ? sanitize_textarea_field( wp_unslash( $og_desc_raw ) ) : '';
if ( empty( $og_desc ) ) {
delete_post_meta( $post_id, '_og_description' );
} else {
update_post_meta( $post_id, '_og_description', $og_desc );
}
}
}

View file

@ -0,0 +1,209 @@
<?php
/**
* REST API Class
*
* Registers and handles REST API endpoints for OpenGraph image fallback.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_REST_API
*
* Provides REST API endpoints for the OpenGraph plugin.
*
* @since 1.0.0
*/
class Robotstxt_OG_REST_API {
/**
* REST API namespace.
*
* @since 1.0.0
* @var string
*/
const NAMESPACE = 'robotstxt-og/v1';
/**
* Image resolver instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Resolver
*/
private Robotstxt_OG_Image_Resolver $resolver;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
*/
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
$this->resolver = $resolver;
}
/**
* Initialize REST API hooks.
*
* @since 1.0.0
*
* @return void
*/
public function init(): void {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register REST API routes.
*
* @since 1.0.0
*
* @return void
*/
public function register_routes(): void {
// Resolve endpoint: force re-resolve a post's fallback image.
register_rest_route(
self::NAMESPACE,
'/resolve/(?P<post_id>[\d]+)',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'handle_resolve' ),
'permission_callback' => array( $this, 'check_permission' ),
'args' => array(
'post_id' => array(
'required' => true,
'validate_callback' => function ( $value ) {
return is_numeric( $value ) && (int) $value > 0;
},
'sanitize_callback' => 'absint',
'description' => __( 'The post ID to resolve the fallback image for.', 'robotstxt-og' ),
),
),
)
);
// Status endpoint: get current cached status for a post.
register_rest_route(
self::NAMESPACE,
'/status/(?P<post_id>[\d]+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'handle_status' ),
'permission_callback' => array( $this, 'check_permission' ),
'args' => array(
'post_id' => array(
'required' => true,
'validate_callback' => function ( $value ) {
return is_numeric( $value ) && (int) $value > 0;
},
'sanitize_callback' => 'absint',
'description' => __( 'The post ID to get the fallback image status for.', 'robotstxt-og' ),
),
),
)
);
}
/**
* Handle the resolve endpoint.
*
* Forces re-resolution of the fallback image for the specified post.
*
* @since 1.0.0
*
* @param WP_REST_Request $request REST request object.
* @return WP_REST_Response|WP_Error Response object.
*/
public function handle_resolve( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
// Verify post exists.
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
__( 'Post not found.', 'robotstxt-og' ),
array( 'status' => 404 )
);
}
// Clear existing cache and resolve.
$this->resolver->clear_cache( $post_id );
$resolved_url = $this->resolver->resolve_image( $post_id );
$data = array(
'post_id' => $post_id,
'resolved_url' => $resolved_url,
'cached' => ! empty( get_post_meta( $post_id, '_og_image_fallback_url', true ) ),
'success' => ! empty( $resolved_url ),
);
return new WP_REST_Response( $data, 200 );
}
/**
* Handle the status endpoint.
*
* Returns the current cached fallback image status for a post.
*
* @since 1.0.0
*
* @param WP_REST_Request $request REST request object.
* @return WP_REST_Response|WP_Error Response object.
*/
public function handle_status( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
// Verify post exists.
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
__( 'Post not found.', 'robotstxt-og' ),
array( 'status' => 404 )
);
}
$cached_url = get_post_meta( $post_id, '_og_image_fallback_url', true );
$thumbnail_id = get_post_thumbnail_id( $post_id );
$original_url = $thumbnail_id ? wp_get_attachment_url( $thumbnail_id ) : '';
$data = array(
'post_id' => $post_id,
'cached_url' => $cached_url,
'original_url' => $original_url,
'has_cache' => ! empty( $cached_url ),
);
return new WP_REST_Response( $data, 200 );
}
/**
* Check REST API permission.
*
* Requires manage_options capability.
*
* @since 1.0.0
*
* @return bool|WP_Error True if authorized, WP_Error otherwise.
*/
public function check_permission() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to access this endpoint.', 'robotstxt-og' ),
array( 'status' => 403 )
);
}
return true;
}
}

View file

@ -0,0 +1,579 @@
<?php
/**
* OG Tags Generator Class
*
* Handles Open Graph and Twitter Card meta tag generation and injection.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Tags
*
* Generates and injects a complete set of Open Graph and Twitter Card meta tags
* for social media crawlers. When no SEO plugin is detected, outputs all standard
* OG tags (title, type, URL, description, site name, locale, image, article-specific).
* When Yoast SEO or RankMath is active, acts as an image corrector only.
*
* @since 1.0.0
*/
class Robotstxt_OG_Tags {
/**
* Image resolver instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Resolver
*/
private Robotstxt_OG_Image_Resolver $resolver;
/**
* Whether to inject tags directly (vs using SEO plugin filters).
*
* @since 1.0.0
* @var bool
*/
private bool $inject_directly = true;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
*/
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
$this->resolver = $resolver;
}
/**
* Initialize hooks.
*
* @since 1.0.0
*
* @return void
*/
public function init(): void {
// Detect SEO plugins and use appropriate integration method.
$this->detect_seo_plugins();
if ( $this->inject_directly ) {
// No SEO plugin detected, inject all tags directly.
add_action( 'wp_head', array( $this, 'inject_og_tags' ), 5 );
}
}
/**
* Detect active SEO plugins and set up filters.
*
* @since 1.0.0
*
* @return void
*/
private function detect_seo_plugins(): void {
// Check for Yoast SEO.
if ( defined( 'WPSEO_VERSION' ) ) {
$this->inject_directly = false;
add_filter( 'wpseo_opengraph_image', array( $this, 'filter_yoast_image' ) );
return;
}
// Check for RankMath.
if ( class_exists( 'RankMath' ) ) {
$this->inject_directly = false;
add_filter( 'rank_math/opengraph/facebook/og_image', array( $this, 'filter_rankmath_image' ) );
return;
}
}
/**
* Inject all Open Graph and Twitter Card meta tags directly.
*
* Outputs a complete set of OG tags (og:title, og:type, og:url, og:description,
* og:site_name, og:locale, og:image and related), article-specific tags when
* applicable, and Twitter Card tags. Respects the enable/disable settings for
* each tag group.
*
* @since 1.0.0
*
* @return void
*/
public function inject_og_tags(): void {
$enable_facebook = (bool) get_option( 'robotstxt_og_enable_facebook', true );
$enable_twitter = (bool) get_option( 'robotstxt_og_enable_twitter', true );
if ( ! $enable_facebook && ! $enable_twitter ) {
return;
}
$image_url = $this->get_contextual_image();
if ( $enable_facebook ) {
// Output all basic OG tags: title, type, url, description, site_name, locale.
$this->output_og_basic_tags();
// Output image tags only when a compatible image is available.
if ( ! empty( $image_url ) ) {
$this->output_og_image_tags( $image_url );
}
// Output article-specific tags (only for singular blog posts).
$this->output_article_tags();
}
if ( $enable_twitter ) {
$this->output_twitter_card_tags( $image_url );
}
}
/**
* Filter Yoast SEO's Open Graph image.
*
* When Yoast SEO is active, this plugin acts as a corrector only,
* replacing incompatible image formats with JPEG/PNG alternatives.
*
* @since 1.0.0
*
* @param string $image Original image URL from Yoast.
* @return string Filtered image URL.
*/
public function filter_yoast_image( string $image ): string {
if ( ! (bool) get_option( 'robotstxt_og_enable_facebook', true ) ) {
return $image;
}
$image_url = $this->get_contextual_image();
return ! empty( $image_url ) ? $image_url : $image;
}
/**
* Filter RankMath's Open Graph image.
*
* When RankMath is active, this plugin acts as a corrector only,
* replacing incompatible image formats with JPEG/PNG alternatives.
*
* @since 1.0.0
*
* @param string $image Original image URL from RankMath.
* @return string Filtered image URL.
*/
public function filter_rankmath_image( string $image ): string {
if ( ! (bool) get_option( 'robotstxt_og_enable_facebook', true ) ) {
return $image;
}
$image_url = $this->get_contextual_image();
return ! empty( $image_url ) ? $image_url : $image;
}
/**
* Get the resolved image for the current context.
*
* Handles homepage, singular pages, and taxonomy archives.
*
* @since 1.0.0
*
* @return string Image URL or empty string.
*/
private function get_contextual_image(): string {
// Homepage / front page.
if ( is_front_page() || is_home() ) {
return $this->resolver->get_homepage_image();
}
// Singular post/page.
if ( is_singular() ) {
$post_id = get_queried_object_id();
if ( empty( $post_id ) ) {
return '';
}
return $this->resolver->get_fallback_image( $post_id );
}
// Taxonomy archive.
if ( is_tax() || is_category() || is_tag() ) {
$term = get_queried_object();
if ( ! ( $term instanceof WP_Term ) ) {
return '';
}
return $this->resolver->get_taxonomy_fallback_image( $term->term_id );
}
return '';
}
/**
* Get the og:title for the current context.
*
* For singular posts/pages, checks for a custom _og_title override first.
*
* @since 1.2.0
*
* @return string The title string.
*/
private function get_og_title(): string {
if ( is_singular() ) {
$post_id = get_queried_object_id();
$custom_title = (string) get_post_meta( $post_id, '_og_title', true );
if ( ! empty( $custom_title ) ) {
return $custom_title;
}
return (string) get_the_title( $post_id );
}
if ( is_front_page() || is_home() ) {
return (string) get_bloginfo( 'name' );
}
if ( is_tax() || is_category() || is_tag() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
return (string) $term->name;
}
}
return (string) get_bloginfo( 'name' );
}
/**
* Get the og:description for the current context.
*
* For singular posts/pages, checks for a custom _og_description override first,
* then falls back to the post excerpt.
*
* @since 1.2.0
*
* @return string The description string.
*/
private function get_og_description(): string {
if ( is_singular() ) {
$post_id = get_queried_object_id();
$custom_desc = (string) get_post_meta( $post_id, '_og_description', true );
if ( ! empty( $custom_desc ) ) {
return $custom_desc;
}
$excerpt = (string) get_post_field( 'post_excerpt', $post_id );
if ( ! empty( $excerpt ) ) {
return wp_strip_all_tags( $excerpt );
}
return '';
}
if ( is_front_page() || is_home() ) {
return (string) get_bloginfo( 'description' );
}
if ( is_tax() || is_category() || is_tag() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term && ! empty( $term->description ) ) {
return wp_strip_all_tags( $term->description );
}
}
return '';
}
/**
* Get the og:url for the current context.
*
* @since 1.2.0
*
* @return string The canonical URL.
*/
private function get_og_url(): string {
if ( is_singular() ) {
return (string) get_permalink();
}
if ( is_front_page() || is_home() ) {
return home_url( '/' );
}
if ( is_tax() || is_category() || is_tag() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
$term_link = get_term_link( $term );
if ( ! is_wp_error( $term_link ) ) {
return (string) $term_link;
}
}
}
return home_url( '/' );
}
/**
* Get the og:type for the current context.
*
* Returns 'article' for singular blog posts (post type = post),
* and 'website' for all other contexts.
*
* @since 1.2.0
*
* @return string 'article' or 'website'.
*/
private function get_og_type(): string {
if ( is_singular( 'post' ) ) {
return 'article';
}
return 'website';
}
/**
* Output basic Open Graph meta tags (non-image).
*
* Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale.
*
* @since 1.2.0
*
* @return void
*/
private function output_og_basic_tags(): void {
$title = $this->get_og_title();
$type = $this->get_og_type();
$url = $this->get_og_url();
$description = $this->get_og_description();
$site_name = (string) get_bloginfo( 'name' );
$locale = (string) get_locale();
if ( ! empty( $title ) ) {
printf(
'<meta property="og:title" content="%s" />' . "\n",
esc_attr( $title )
);
}
printf(
'<meta property="og:type" content="%s" />' . "\n",
esc_attr( $type )
);
if ( ! empty( $url ) ) {
printf(
'<meta property="og:url" content="%s" />' . "\n",
esc_url( $url )
);
}
if ( ! empty( $description ) ) {
printf(
'<meta property="og:description" content="%s" />' . "\n",
esc_attr( $description )
);
}
if ( ! empty( $site_name ) ) {
printf(
'<meta property="og:site_name" content="%s" />' . "\n",
esc_attr( $site_name )
);
}
if ( ! empty( $locale ) ) {
printf(
'<meta property="og:locale" content="%s" />' . "\n",
esc_attr( $locale )
);
}
}
/**
* Output Open Graph image meta tags.
*
* Outputs og:image, og:image:secure_url (HTTPS only), og:image:width,
* og:image:height, og:image:type, and og:image:alt when available.
*
* @since 1.0.0
*
* @param string $image_url Image URL.
* @return void
*/
private function output_og_image_tags( string $image_url ): void {
// Basic OG image tag.
printf(
'<meta property="og:image" content="%s" />' . "\n",
esc_url( $image_url )
);
// Secure URL for HTTPS.
if ( is_ssl() ) {
printf(
'<meta property="og:image:secure_url" content="%s" />' . "\n",
esc_url( $image_url )
);
}
// Try to get dimensions, MIME type, and alt text from the media library.
$image_id = attachment_url_to_postid( $image_url );
if ( $image_id ) {
$metadata = wp_get_attachment_metadata( $image_id );
if ( ! empty( $metadata['width'] ) ) {
printf(
'<meta property="og:image:width" content="%d" />' . "\n",
absint( $metadata['width'] )
);
}
if ( ! empty( $metadata['height'] ) ) {
printf(
'<meta property="og:image:height" content="%d" />' . "\n",
absint( $metadata['height'] )
);
}
$mime_type = get_post_mime_type( $image_id );
if ( ! empty( $mime_type ) ) {
printf(
'<meta property="og:image:type" content="%s" />' . "\n",
esc_attr( $mime_type )
);
}
// Alt text from the media library (set when uploading/editing the image).
$alt_text = (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true );
if ( ! empty( $alt_text ) ) {
printf(
'<meta property="og:image:alt" content="%s" />' . "\n",
esc_attr( $alt_text )
);
}
}
}
/**
* Output article-specific Open Graph meta tags.
*
* Outputs article:published_time, article:modified_time, article:section,
* and article:tag. Only runs when the current page is a singular blog post.
*
* @since 1.2.0
*
* @return void
*/
private function output_article_tags(): void {
if ( ! is_singular( 'post' ) ) {
return;
}
$post = get_queried_object();
if ( ! ( $post instanceof WP_Post ) ) {
return;
}
$published = get_the_date( 'c', $post );
if ( ! empty( $published ) ) {
printf(
'<meta property="article:published_time" content="%s" />' . "\n",
esc_attr( $published )
);
}
$modified = get_the_modified_date( 'c', $post );
if ( ! empty( $modified ) ) {
printf(
'<meta property="article:modified_time" content="%s" />' . "\n",
esc_attr( $modified )
);
}
// Primary category as og:article:section.
$categories = get_the_category( $post->ID );
if ( ! empty( $categories ) ) {
printf(
'<meta property="article:section" content="%s" />' . "\n",
esc_attr( $categories[0]->name )
);
}
// Post tags as og:article:tag (one tag per meta tag).
$tags = get_the_tags( $post->ID );
if ( is_array( $tags ) ) {
foreach ( $tags as $tag ) {
printf(
'<meta property="article:tag" content="%s" />' . "\n",
esc_attr( $tag->name )
);
}
}
}
/**
* Output Twitter Card meta tags.
*
* Outputs twitter:card (always), twitter:site (when configured),
* and twitter:image (when an image is available). Twitter falls back
* to og:title, og:description automatically, so those are not duplicated.
*
* @since 1.1.0
*
* @param string $image_url Image URL, or empty string if no image available.
* @return void
*/
private function output_twitter_card_tags( string $image_url ): void {
$card_type = (string) get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
if ( ! in_array( $card_type, array( 'summary', 'summary_large_image' ), true ) ) {
$card_type = 'summary_large_image';
}
// twitter:card is mandatory for Twitter Cards to function.
printf(
'<meta name="twitter:card" content="%s" />' . "\n",
esc_attr( $card_type )
);
// Site handle (e.g. @example).
$twitter_site = sanitize_text_field( (string) get_option( 'robotstxt_og_twitter_site', '' ) );
if ( ! empty( $twitter_site ) ) {
// Ensure the handle includes the @ prefix.
if ( '@' !== substr( $twitter_site, 0, 1 ) ) {
$twitter_site = '@' . $twitter_site;
}
printf(
'<meta name="twitter:site" content="%s" />' . "\n",
esc_attr( $twitter_site )
);
}
// Image (only when available; Twitter falls back to og:image otherwise).
if ( ! empty( $image_url ) ) {
printf(
'<meta name="twitter:image" content="%s" />' . "\n",
esc_url( $image_url )
);
}
}
}

Binary file not shown.

View file

@ -0,0 +1,427 @@
# Translation of OpenGraph (by ROBOTSTXT) in Spanish (Spain)
# This file is distributed under the same license as the OpenGraph (by ROBOTSTXT) package.
# Copyright (C) 2026 ROBOTSTXT
#
msgid ""
msgstr ""
"Project-Id-Version: OpenGraph (by ROBOTSTXT) 1.1.0\n"
"Report-Msgid-Bugs-To: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/issues\n"
"POT-Creation-Date: 2026-02-18\n"
"PO-Revision-Date: 2026-02-18 00:00+0000\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Spanish (Spain)\n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Manual\n"
"X-Domain: robotstxt-og\n"
#. Plugin Name of the plugin
msgid "OpenGraph (by ROBOTSTXT)"
msgstr "OpenGraph (by ROBOTSTXT)"
#. Plugin URI of the plugin
msgid "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og"
msgstr "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og"
#. Description of the plugin
msgid "Intelligent Open Graph image fallback for social media crawlers. Automatically detects and serves compatible image formats (JPEG/PNG) when modern formats (AVIF/WebP) are used as featured images."
msgstr "Imagen de respaldo inteligente para Open Graph en rastreadores de redes sociales. Detecta y sirve automáticamente formatos de imagen compatibles (JPEG/PNG) cuando se usan formatos modernos (AVIF/WebP) como imágenes destacadas."
#: admin/class-robotstxt-og-admin-settings.php
msgid "OpenGraph Settings"
msgstr "Ajustes de OpenGraph"
#: admin/class-robotstxt-og-admin-settings.php
msgid "OpenGraph"
msgstr "OpenGraph"
#: admin/class-robotstxt-og-admin-settings.php
msgid "You do not have sufficient permissions to access this page."
msgstr "No tienes permisos suficientes para acceder a esta página."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Security check failed"
msgstr "Fallo en la verificación de seguridad"
#: admin/class-robotstxt-og-admin-settings.php
msgid "General Settings"
msgstr "Ajustes generales"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Configure OpenGraph image fallback behavior."
msgstr "Configura el comportamiento de la imagen de respaldo para OpenGraph."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Global Fallback Image"
msgstr "Imagen de respaldo global"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Homepage Image"
msgstr "Imagen de la página principal"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Data Management"
msgstr "Gestión de datos"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Social Media Tags"
msgstr "Etiquetas para redes sociales"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Choose which social media meta tag groups to output and configure their behavior."
msgstr "Elige qué grupos de metaetiquetas para redes sociales deseas generar y configura su comportamiento."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Facebook / OG Tags"
msgstr "Etiquetas Facebook / OG"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Twitter Card Tags"
msgstr "Etiquetas Twitter Card"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Twitter Card Type"
msgstr "Tipo de Twitter Card"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Select Image"
msgstr "Seleccionar imagen"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Remove Image"
msgstr "Eliminar imagen"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Used as Open Graph image when a post has no featured image or when no compatible format (JPEG/PNG) is found."
msgstr "Se usa como imagen de Open Graph cuando una entrada no tiene imagen destacada o no se encuentra un formato compatible (JPEG/PNG)."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Dedicated Open Graph image for the site homepage. Falls back to the Global Fallback Image if not set."
msgstr "Imagen de Open Graph dedicada a la página principal del sitio. Si no se configura, se usará la imagen de respaldo global."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Delete all plugin data when uninstalling"
msgstr "Eliminar todos los datos del plugin al desinstalar"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Warning: This will permanently delete all cached fallback URLs and plugin settings. This action cannot be undone."
msgstr "Advertencia: Esto eliminará permanentemente todas las URLs de respaldo en caché y los ajustes del plugin. Esta acción no se puede deshacer."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Enable Facebook / Open Graph image tags"
msgstr "Habilitar etiquetas de imagen para Facebook / Open Graph"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, and og:image:alt meta tags."
msgstr "Genera las metaetiquetas og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type y og:image:alt."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Enable Twitter Card image tags"
msgstr "Habilitar etiquetas de imagen para Twitter Card"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Outputs twitter:card, twitter:site, and twitter:image meta tags."
msgstr "Genera las metaetiquetas twitter:card, twitter:site y twitter:image."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Summary with large image"
msgstr "Resumen con imagen grande"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Summary (small image)"
msgstr "Resumen (imagen pequeña)"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Controls the twitter:card meta tag value. \"Summary with large image\" is recommended for most sites."
msgstr "Controla el valor de la metaetiqueta twitter:card. Se recomienda «Resumen con imagen grande» para la mayoría de los sitios."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Twitter/X Site Handle"
msgstr "Cuenta Twitter/X del sitio"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Your site's Twitter/X account handle (e.g. @example). Used for the twitter:site meta tag."
msgstr "La cuenta Twitter/X de tu sitio (p. ej. @ejemplo). Se usa para la metaetiqueta twitter:site."
#: admin/class-robotstxt-og-admin-settings.php
#. translators: %d: number of cleared cache entries
msgid "Successfully cleared %d cached fallback URLs."
msgstr "Se han eliminado correctamente %d URLs de respaldo en caché."
#: admin/class-robotstxt-og-admin-settings.php
#. translators: 1: number of successful resolutions 2: number of failed resolutions
msgid "Batch resolution complete. Success: %1$d, Failed: %2$d"
msgstr "Resolución en lote completada. Correctas: %1$d, Fallidas: %2$d"
#: admin/class-robotstxt-og-admin-settings.php
#. translators: %d: post ID
msgid "Successfully cleared cached fallback URL for post #%d."
msgstr "URL de respaldo en caché eliminada correctamente para la entrada #%d."
#: admin/views/settings-page.php
msgid "Settings tabs"
msgstr "Pestañas de ajustes"
#: admin/views/settings-page.php
msgid "Settings"
msgstr "Ajustes"
#: admin/views/settings-page.php
msgid "Tools"
msgstr "Herramientas"
#: admin/views/settings-page.php
msgid "Diagnostics"
msgstr "Diagnósticos"
#: admin/views/settings-page.php
msgid "Save Settings"
msgstr "Guardar ajustes"
#: admin/views/settings-page.php
msgid "Cache Management"
msgstr "Gestión de caché"
#: admin/views/settings-page.php
msgid "Use these tools to manage cached fallback image URLs."
msgstr "Usa estas herramientas para gestionar las URLs de imagen de respaldo almacenadas en caché."
#: admin/views/settings-page.php
msgid "Clear All Caches"
msgstr "Vaciar toda la caché"
#: admin/views/settings-page.php
msgid "Clear All Cached URLs"
msgstr "Eliminar todas las URLs en caché"
#: admin/views/settings-page.php
msgid "Delete all cached fallback URLs. Images will be re-resolved on next page view."
msgstr "Elimina todas las URLs de respaldo en caché. Las imágenes se resolverán de nuevo en la próxima visita a la página."
#: admin/views/settings-page.php
msgid "Re-resolve All Images"
msgstr "Volver a resolver todas las imágenes"
#: admin/views/settings-page.php
msgid "Re-resolve All Images Now"
msgstr "Volver a resolver todas las imágenes ahora"
#: admin/views/settings-page.php
msgid "Clear cache and immediately re-resolve all posts with featured images. May take time on large sites."
msgstr "Vacía la caché y vuelve a resolver inmediatamente todas las entradas con imagen destacada. Puede tardar en sitios grandes."
#: admin/views/settings-page.php
msgid "Plugin Information"
msgstr "Información del plugin"
#: admin/views/settings-page.php
msgid "Version"
msgstr "Versión"
#: admin/views/settings-page.php
msgid "Documentation"
msgstr "Documentación"
#: admin/views/settings-page.php
msgid "View Documentation"
msgstr "Ver documentación"
#: admin/views/settings-page.php
msgid "Support"
msgstr "Soporte"
#: admin/views/settings-page.php
msgid "Report an Issue"
msgstr "Informar de un problema"
#: admin/views/settings-page.php
msgid "Statistics"
msgstr "Estadísticas"
#: admin/views/settings-page.php
msgid "Posts with featured images"
msgstr "Entradas con imagen destacada"
#: admin/views/settings-page.php
msgid "Posts with cached fallback URLs"
msgstr "Entradas con URL de respaldo en caché"
#: admin/views/settings-page.php
msgid "Coverage"
msgstr "Cobertura"
#: admin/views/settings-page.php
msgid "N/A"
msgstr "N/D"
#: admin/views/settings-page.php
msgid "Test URL"
msgstr "Probar URL"
#: admin/views/settings-page.php
msgid "Test whether a URL is reachable via HTTP HEAD request."
msgstr "Comprueba si una URL es accesible mediante una petición HTTP HEAD."
#: admin/views/settings-page.php
msgid "URL to test"
msgstr "URL a probar"
#: admin/views/settings-page.php
msgid "Test Result"
msgstr "Resultado de la prueba"
#: admin/views/settings-page.php
msgid "Error:"
msgstr "Error:"
#: admin/views/settings-page.php
msgid "HTTP Status:"
msgstr "Estado HTTP:"
#: admin/views/settings-page.php
msgid "Header"
msgstr "Cabecera"
#: admin/views/settings-page.php
msgid "Value"
msgstr "Valor"
#: admin/views/settings-page.php
#. translators: %d: total entries
msgid "Cached Fallback URLs (%d)"
msgstr "URLs de respaldo en caché (%d)"
#: admin/views/settings-page.php
msgid "No cached fallback URLs found."
msgstr "No se encontraron URLs de respaldo en caché."
#: admin/views/settings-page.php
msgid "Post ID"
msgstr "ID de entrada"
#: admin/views/settings-page.php
msgid "Title"
msgstr "Título"
#: admin/views/settings-page.php
msgid "Type"
msgstr "Tipo"
#: admin/views/settings-page.php
msgid "Cached Fallback URL"
msgstr "URL de respaldo en caché"
#: admin/views/settings-page.php
msgid "Actions"
msgstr "Acciones"
#: admin/views/settings-page.php
msgid "Clear"
msgstr "Limpiar"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of posts found
msgid "Found %d posts with featured images. (dry-run, no changes made)"
msgstr "Se encontraron %d entradas con imagen destacada. (simulación, no se realizaron cambios)"
#: includes/class-robotstxt-og-cli.php
msgid "No posts with featured images found."
msgstr "No se encontraron entradas con imagen destacada."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of posts to process
msgid "Resolving %d posts"
msgstr "Resolviendo %d entradas"
#: includes/class-robotstxt-og-cli.php
#. translators: 1: successful count, 2: failed count
msgid "Resolved %1$d posts. Failed: %2$d."
msgstr "Resueltas %1$d entradas. Fallidas: %2$d."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Post %d not found."
msgstr "Entrada %d no encontrada."
#: includes/class-robotstxt-og-cli.php
#. translators: 1: post ID, 2: image URL
msgid "Post %1$d has featured image: %2$s (dry-run, no changes made)"
msgstr "La entrada %1$d tiene imagen destacada: %2$s (simulación, no se realizaron cambios)"
#: includes/class-robotstxt-og-cli.php
#. translators: 1: post ID, 2: resolved URL
msgid "Post %1$d resolved to %2$s"
msgstr "La entrada %1$d se ha resuelto a %2$s"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Post %d: no compatible image found."
msgstr "Entrada %d: no se encontró ninguna imagen compatible."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of cache entries found
msgid "Found %d cached fallback URLs. (dry-run, no changes made)"
msgstr "Se encontraron %d URLs de respaldo en caché. (simulación, no se realizaron cambios)"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of cleared entries
msgid "Cleared %d cached fallback URLs."
msgstr "Se han eliminado %d URLs de respaldo en caché."
#: includes/class-robotstxt-og-cli.php
#. translators: 1: post ID, 2: cached URL or 'none'
msgid "Post %1$d cached URL: %2$s (dry-run, no changes made)"
msgstr "URL en caché de la entrada %1$d: %2$s (simulación, no se realizaron cambios)"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Post %d has no cached fallback URL."
msgstr "La entrada %d no tiene URL de respaldo en caché."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Cleared cached fallback URL for post %d."
msgstr "Se ha eliminado la URL de respaldo en caché para la entrada %d."
#: includes/class-robotstxt-og-cli.php
msgid "Please specify a post ID or use --all flag."
msgstr "Especifica un ID de entrada o usa la opción --all."
#: includes/class-robotstxt-og-rest-api.php
msgid "Post not found."
msgstr "Entrada no encontrada."
#: includes/class-robotstxt-og-rest-api.php
msgid "You do not have permission to access this endpoint."
msgstr "No tienes permiso para acceder a este endpoint."
#: includes/class-robotstxt-og-rest-api.php
msgid "The post ID to resolve the fallback image for."
msgstr "El ID de entrada para el que resolver la imagen de respaldo."
#: includes/class-robotstxt-og-rest-api.php
msgid "The post ID to get the fallback image status for."
msgstr "El ID de entrada del que obtener el estado de la imagen de respaldo."
#: includes/class-robotstxt-og-meta-box.php
msgid "Open Graph / Social Media"
msgstr "Open Graph / Redes Sociales"
#: includes/class-robotstxt-og-meta-box.php
msgid "Custom Title"
msgstr "Título personalizado"
#: includes/class-robotstxt-og-meta-box.php
msgid "Overrides the default og:title for this post. Leave blank to use the post title automatically."
msgstr "Reemplaza el og:title predeterminado para esta entrada. Déjalo en blanco para usar el título de la entrada automáticamente."
#: includes/class-robotstxt-og-meta-box.php
msgid "Custom Description"
msgstr "Descripción personalizada"
#: includes/class-robotstxt-og-meta-box.php
msgid "Overrides the default og:description for this post. Leave blank to use the excerpt automatically."
msgstr "Reemplaza el og:description predeterminado para esta entrada. Déjalo en blanco para usar el extracto automáticamente."

476
languages/robotstxt-og.pot Normal file
View file

@ -0,0 +1,476 @@
# Copyright (C) 2026 ROBOTSTXT
# This file is distributed under the GPL v3 or later.
msgid ""
msgstr ""
"Project-Id-Version: OpenGraph (by ROBOTSTXT) 1.0.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-og\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-02-18T06:52:54+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: robotstxt-og\n"
#. Plugin Name of the plugin
#: robotstxt-og.php
msgid "OpenGraph (by ROBOTSTXT)"
msgstr ""
#. Plugin URI of the plugin
#: robotstxt-og.php
msgid "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og"
msgstr ""
#. Description of the plugin
#: robotstxt-og.php
msgid "Intelligent Open Graph image fallback for social media crawlers. Automatically detects and serves compatible image formats (JPEG/PNG) when modern formats (AVIF/WebP) are used as featured images."
msgstr ""
#. Author of the plugin
#: robotstxt-og.php
msgid "ROBOTSTXT"
msgstr ""
#. Author URI of the plugin
#: robotstxt-og.php
msgid "https://www.robotstxt.es/"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:84
#: admin/class-robotstxt-og-admin-settings.php:526
#: admin/class-robotstxt-og-admin-settings.php:573
msgid "Security check failed"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:89
#: admin/class-robotstxt-og-admin-settings.php:500
#: admin/class-robotstxt-og-admin-settings.php:531
#: admin/class-robotstxt-og-admin-settings.php:578
msgid "You do not have sufficient permissions to access this page."
msgstr ""
#. translators: %d: post ID
#: admin/class-robotstxt-og-admin-settings.php:100
#, php-format
msgid "Successfully cleared cached fallback URL for post #%d."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:121
msgid "OpenGraph Settings"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:122
msgid "OpenGraph"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:211
msgid "General Settings"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:218
msgid "Global Fallback Image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:226
#: admin/class-robotstxt-og-admin-settings.php:347
msgid "Homepage Image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:234
msgid "Data Management"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:243
msgid "Social Media Tags"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:250
msgid "Facebook / OG Tags"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:258
msgid "Twitter Card Tags"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:266
msgid "Twitter Card Type"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:274
msgid "Twitter/X Site Handle"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:290
msgid "Configure OpenGraph image fallback behavior."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:311
msgid "Fallback Image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:316
#: admin/class-robotstxt-og-admin-settings.php:352
#: admin/class-robotstxt-og-admin-settings.php:699
msgid "Select Image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:320
#: admin/class-robotstxt-og-admin-settings.php:356
msgid "Remove Image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:324
msgid "Used as Open Graph image when a post has no featured image or when no compatible format (JPEG/PNG) is found."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:360
msgid "Dedicated Open Graph image for the site homepage. Falls back to the Global Fallback Image if not set."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:378
msgid "Delete all plugin data when uninstalling"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:381
msgid "Warning: This will permanently delete all cached fallback URLs and plugin settings. This action cannot be undone."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:395
msgid "Choose which social media meta tag groups to output and configure their behavior."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:411
msgid "Enable Facebook / Open Graph image tags"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:414
msgid "Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, and og:image:alt meta tags."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:431
msgid "Enable Twitter Card image tags"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:434
msgid "Outputs twitter:card, twitter:site, and twitter:image meta tags."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:449
msgid "Summary with large image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:450
msgid "Summary (small image)"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:461
msgid "Controls the twitter:card meta tag value. \"Summary with large image\" is recommended for most sites."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:485
msgid "Your site's Twitter/X account handle (e.g. @example). Used for the twitter:site meta tag."
msgstr ""
#. translators: %d: number of cleared cache entries
#: admin/class-robotstxt-og-admin-settings.php:542
#, php-format
msgid "Successfully cleared %d cached fallback URLs."
msgstr ""
#. translators: 1: number of successful resolutions 2: number of failed resolutions
#: admin/class-robotstxt-og-admin-settings.php:589
#, php-format
msgid "Batch resolution complete. Success: %1$d, Failed: %2$d"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:700
msgid "Use This Image"
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:701
msgid "Are you sure you want to clear all cached fallback URLs? This action cannot be undone."
msgstr ""
#: admin/class-robotstxt-og-admin-settings.php:702
msgid "Are you sure you want to re-resolve all images? This may take some time on large sites."
msgstr ""
#: admin/views/settings-page.php:33
msgid "Settings tabs"
msgstr ""
#: admin/views/settings-page.php:36
msgid "Settings"
msgstr ""
#: admin/views/settings-page.php:40
msgid "Tools"
msgstr ""
#: admin/views/settings-page.php:44
msgid "Diagnostics"
msgstr ""
#: admin/views/settings-page.php:56
msgid "Save Settings"
msgstr ""
#: admin/views/settings-page.php:62
msgid "Cache Management"
msgstr ""
#: admin/views/settings-page.php:64
msgid "Use these tools to manage cached fallback image URLs."
msgstr ""
#: admin/views/settings-page.php:70
msgid "Clear All Caches"
msgstr ""
#: admin/views/settings-page.php:89
msgid "Clear All Cached URLs"
msgstr ""
#: admin/views/settings-page.php:92
msgid "Delete all cached fallback URLs. Images will be re-resolved on next page view."
msgstr ""
#: admin/views/settings-page.php:98
msgid "Re-resolve All Images"
msgstr ""
#: admin/views/settings-page.php:117
msgid "Re-resolve All Images Now"
msgstr ""
#: admin/views/settings-page.php:120
msgid "Clear cache and immediately re-resolve all posts with featured images. May take time on large sites."
msgstr ""
#: admin/views/settings-page.php:127
msgid "Plugin Information"
msgstr ""
#: admin/views/settings-page.php:132
msgid "Version"
msgstr ""
#: admin/views/settings-page.php:136
msgid "Documentation"
msgstr ""
#: admin/views/settings-page.php:139
msgid "View Documentation"
msgstr ""
#: admin/views/settings-page.php:144
msgid "Support"
msgstr ""
#: admin/views/settings-page.php:147
msgid "Report an Issue"
msgstr ""
#: admin/views/settings-page.php:194
msgid "Statistics"
msgstr ""
#: admin/views/settings-page.php:199
msgid "Posts with featured images"
msgstr ""
#: admin/views/settings-page.php:203
msgid "Posts with cached fallback URLs"
msgstr ""
#: admin/views/settings-page.php:207
msgid "Coverage"
msgstr ""
#: admin/views/settings-page.php:213
msgid "N/A"
msgstr ""
#: admin/views/settings-page.php:221
#: admin/views/settings-page.php:247
msgid "Test URL"
msgstr ""
#: admin/views/settings-page.php:223
msgid "Test whether a URL is reachable via HTTP HEAD request."
msgstr ""
#: admin/views/settings-page.php:232
msgid "URL to test"
msgstr ""
#: admin/views/settings-page.php:267
msgid "Test Result"
msgstr ""
#: admin/views/settings-page.php:271
msgid "Error:"
msgstr ""
#: admin/views/settings-page.php:279
msgid "HTTP Status:"
msgstr ""
#: admin/views/settings-page.php:285
msgid "Header"
msgstr ""
#: admin/views/settings-page.php:286
msgid "Value"
msgstr ""
#. translators: %d: total entries
#: admin/views/settings-page.php:301
#, php-format
msgid "Cached Fallback URLs (%d)"
msgstr ""
#: admin/views/settings-page.php:306
#: includes/class-robotstxt-og-cli.php:289
msgid "No cached fallback URLs found."
msgstr ""
#: admin/views/settings-page.php:312
msgid "Post ID"
msgstr ""
#: admin/views/settings-page.php:313
msgid "Title"
msgstr ""
#: admin/views/settings-page.php:314
msgid "Type"
msgstr ""
#: admin/views/settings-page.php:315
msgid "Cached Fallback URL"
msgstr ""
#: admin/views/settings-page.php:316
msgid "Actions"
msgstr ""
#: admin/views/settings-page.php:351
msgid "Clear"
msgstr ""
#. translators: %d: number of posts found
#: includes/class-robotstxt-og-cli.php:187
#, php-format
msgid "Found %d posts with featured images. (dry-run, no changes made)"
msgstr ""
#: includes/class-robotstxt-og-cli.php:193
msgid "No posts with featured images found."
msgstr ""
#. translators: %d: number of posts to process
#: includes/class-robotstxt-og-cli.php:201
#, php-format
msgid "Resolving %d posts"
msgstr ""
#. translators: 1: successful count, 2: failed count
#: includes/class-robotstxt-og-cli.php:222
#, php-format
msgid "Resolved %1$d posts. Failed: %2$d."
msgstr ""
#. translators: %d: post ID
#: includes/class-robotstxt-og-cli.php:240
#, php-format
msgid "Post %d not found."
msgstr ""
#. translators: 1: post ID, 2: image URL
#: includes/class-robotstxt-og-cli.php:248
#, php-format
msgid "Post %1$d has featured image: %2$s (dry-run, no changes made)"
msgstr ""
#. translators: 1: post ID, 2: resolved URL
#: includes/class-robotstxt-og-cli.php:257
#, php-format
msgid "Post %1$d resolved to %2$s"
msgstr ""
#. translators: %d: post ID
#: includes/class-robotstxt-og-cli.php:260
#, php-format
msgid "Post %d: no compatible image found."
msgstr ""
#. translators: %d: number of cache entries found
#: includes/class-robotstxt-og-cli.php:284
#, php-format
msgid "Found %d cached fallback URLs. (dry-run, no changes made)"
msgstr ""
#. translators: %d: number of cleared entries
#: includes/class-robotstxt-og-cli.php:300
#, php-format
msgid "Cleared %d cached fallback URLs."
msgstr ""
#. translators: 1: post ID, 2: cached URL or 'none'
#: includes/class-robotstxt-og-cli.php:317
#, php-format
msgid "Post %1$d cached URL: %2$s (dry-run, no changes made)"
msgstr ""
#. translators: %d: post ID
#: includes/class-robotstxt-og-cli.php:323
#, php-format
msgid "Post %d has no cached fallback URL."
msgstr ""
#. translators: %d: post ID
#: includes/class-robotstxt-og-cli.php:329
#, php-format
msgid "Cleared cached fallback URL for post %d."
msgstr ""
#: includes/class-robotstxt-og-meta-box.php:101
msgid "Open Graph / Social Media"
msgstr ""
#: includes/class-robotstxt-og-meta-box.php:130
msgid "Custom Title"
msgstr ""
#: includes/class-robotstxt-og-meta-box.php:142
msgid "Overrides the default og:title for this post. Leave blank to use the post title automatically."
msgstr ""
#: includes/class-robotstxt-og-meta-box.php:149
msgid "Custom Description"
msgstr ""
#: includes/class-robotstxt-og-meta-box.php:160
msgid "Overrides the default og:description for this post. Leave blank to use the excerpt automatically."
msgstr ""
#: includes/class-robotstxt-og-rest-api.php:85
msgid "The post ID to resolve the fallback image for."
msgstr ""
#: includes/class-robotstxt-og-rest-api.php:106
msgid "The post ID to get the fallback image status for."
msgstr ""
#: includes/class-robotstxt-og-rest-api.php:132
#: includes/class-robotstxt-og-rest-api.php:170
msgid "Post not found."
msgstr ""
#: includes/class-robotstxt-og-rest-api.php:202
msgid "You do not have permission to access this endpoint."
msgstr ""

View file

@ -0,0 +1,209 @@
=== OpenGraph (by ROBOTSTXT) ===
Contributors: javiercasares, robotstxt
Tags: opengraph, open graph, twitter card, social media, seo
Requires at least: 6.7
Tested up to: 6.9
Stable tag: 1.0.0
Requires PHP: 8.2
Version: 1.0.0
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
Intelligent Open Graph meta tags with automatic image fallback from AVIF/WebP to JPEG/PNG for social media crawlers.
== Description ==
Social media crawlers — Facebook, Twitter/X, LinkedIn, WhatsApp — do not support modern image formats such as AVIF and WebP. When a post's featured image uses one of these formats, the platform shows a broken or missing image preview.
**OpenGraph (by ROBOTSTXT)** outputs a complete set of Open Graph and Twitter Card meta tags and solves the format incompatibility automatically. It detects whether each post's featured image is in a supported format and, when needed, resolves and caches a compatible JPEG or PNG alternative.
**How it works:**
1. A visitor (or social media crawler) requests a page.
2. The plugin checks whether the post's featured image is in AVIF, WebP, or another unsupported format.
3. If so, it locates a JPEG or PNG version of the same image using HTTP HEAD requests.
4. The result is cached in postmeta so subsequent requests are instant.
5. The resolved image is injected into `og:image` (and related) meta tags.
**Key features:**
* Full Open Graph meta tag output: `og:title`, `og:type`, `og:url`, `og:description`, `og:site_name`, `og:locale`, `og:image`, `og:image:width`, `og:image:height`, `og:image:type`, `og:image:alt`
* Article-specific tags: `article:published_time`, `article:modified_time`, `article:section`, `article:tag`
* Twitter Card tags: `twitter:card`, `twitter:site`, `twitter:image`
* Automatic format detection — AVIF, WebP, GIF, BMP, SVG, TIFF
* HTTP HEAD-based image verification (no file downloads)
* Per-post custom OG title and description override (post editor meta box)
* Global fallback image URL and homepage-specific image URL
* Cached results in postmeta for performance, with automatic invalidation on featured image change
* Negative caching for failed resolutions (1-hour transient)
* Integration with Yoast SEO and RankMath (no duplicate tags)
* Taxonomy archive image support via developer filter
* Admin panel with Settings, Tools, and Diagnostics tabs
* WP-CLI commands for batch operations
* REST API endpoints for programmatic access
* Multisite compatible (per-site configuration)
* Full internationalization support (es_ES included)
== Using the plugin ==
= WP-CLI =
List available commands:
`wp og-fallback --help`
Resolve the fallback image for a single post:
`wp og-fallback resolve 123`
Re-resolve all posts (clears cache first):
`wp og-fallback resolve --all`
Dry-run re-resolution without saving:
`wp og-fallback resolve --all --dry-run`
Re-resolve all posts of a specific type:
`wp og-fallback resolve --all --post-type=page`
Clear cache for a single post:
`wp og-fallback clear-cache 123`
Clear all cached fallback URLs:
`wp og-fallback clear-cache --all`
= REST API =
The plugin exposes two REST API endpoints under the `robotstxt-og/v1` namespace. Both require the `manage_options` capability (administrator authentication).
**Force re-resolve a post's fallback image:**
`POST /wp-json/robotstxt-og/v1/resolve/{post_id}`
Returns: `{ post_id, resolved_url, cached, success }`
**Get current cached status for a post:**
`GET /wp-json/robotstxt-og/v1/status/{post_id}`
Returns: `{ post_id, cached_url, original_url, has_cache }`
== Extra Configurations ==
Developer filters available in `functions.php` or a custom plugin:
**Disable external image resolution:**
`add_filter( 'robotstxt_og_external_image_enabled', '__return_false' );`
**Adjust HEAD request timeout (seconds, default: 5):**
`add_filter( 'robotstxt_og_external_image_timeout', fn() => 10 );`
**Provide an OG image for taxonomy archives:**
`add_filter( 'robotstxt_og_taxonomy_image', fn( $url, $term_id ) => wp_get_attachment_url( get_term_meta( $term_id, 'my_image_id', true ) ), 10, 2 );`
**Enable debug logging to debug.log:**
`add_filter( 'robotstxt_og_enable_logging', '__return_true' );`
== Installation ==
= Automatic download =
Visit the plugin section in your WordPress dashboard, search for **OpenGraph by ROBOTSTXT**, then download and install the plugin.
= Manual download =
Extract the contents of the ZIP and upload the folder to the `/wp-content/plugins/robotstxt-og/` directory. Once uploaded, it will appear in your plugin list and can be activated normally.
= After activation =
1. Go to **Settings > OpenGraph**.
2. Optionally set a **Global Fallback Image** (paste a direct JPEG or PNG URL) to use when no compatible alternative is found.
3. Optionally set a **Homepage Image** for the site front page.
4. Configure your **Twitter/X site handle** (e.g. `@example`) if you want `twitter:site` output.
5. Use the **Tools** tab to re-resolve all existing posts in one click.
6. Use the **Diagnostics** tab to view cached entries and test individual URLs.
== Frequently Asked Questions ==
= Does this plugin duplicate og:image tags from Yoast SEO or RankMath? =
No. When Yoast SEO or RankMath is active, the plugin hooks into their existing filter (`wpseo_opengraph_image` or `rank_math/opengraph/facebook/og_image`) instead of injecting tags directly. The result is a single `og:image` tag, with the resolved fallback applied only when needed.
= Will it work if my images are stored on an external CDN? =
Yes. The plugin sends HTTP HEAD requests to verify alternative URLs, so it works with any URL-accessible image regardless of where it is hosted. You can disable external image resolution entirely with the `robotstxt_og_external_image_enabled` filter if needed.
= Why aren't AVIF/WebP images supported directly? =
AVIF and WebP are not supported by the Open Graph specification as implemented by major social media crawlers (Facebook, LinkedIn, WhatsApp, etc.). These platforms render a broken image or no image at all when an unsupported format is provided. This plugin works around that limitation by serving a JPEG/PNG fallback.
= What happens if no JPEG/PNG alternative is found? =
The plugin falls back to the **Global Fallback Image** configured in Settings > OpenGraph. If no fallback image is set, no `og:image` tag is injected for that post, leaving any existing tag from your SEO plugin in place.
= Is the cache refreshed automatically? =
Yes. When you change or remove a post's featured image, the cached fallback URL is cleared automatically. The new image will be resolved on the next page view, or can be forced immediately via WP-CLI (`wp og-fallback resolve <id>`) or the REST API.
= Does it work on WordPress Multisite? =
Yes. Each site in the network has its own independent configuration. There is no network-wide settings panel.
= Can I use this with classic themes and block themes? =
Yes. The plugin hooks into `wp_head` for direct tag injection and filters Yoast/RankMath when those plugins are active. It is compatible with both classic and block-based themes.
== Compatibility ==
* WordPress: 6.7 - 6.9
* PHP: 8.2 - 8.5
* WP-CLI: 2.x
* MariaDB: 10.6+
**Compatible SEO plugins:**
* Yoast SEO (integration via `wpseo_opengraph_image` filter)
* RankMath (integration via `rank_math/opengraph/facebook/og_image` filter)
== Changelog ==
= 1.0.0 =
_Release date: 2026-02-18_
* Initial stable release.
* Full Open Graph meta tag output: `og:title`, `og:type`, `og:url`, `og:description`, `og:site_name`, `og:locale`, `og:image`, `og:image:width`, `og:image:height`, `og:image:type`, `og:image:alt`.
* Article-specific tags for singular posts: `article:published_time`, `article:modified_time`, `article:section`, `article:tag`.
* Twitter Card tags: `twitter:card`, `twitter:site`, `twitter:image`.
* Automatic format detection (AVIF, WebP, GIF, BMP, SVG, TIFF) with HTTP HEAD-based JPEG/PNG fallback resolution.
* Postmeta caching with automatic invalidation on featured image change.
* Per-post OG title and description overrides via post editor meta box.
* Global fallback image URL and homepage-specific image URL settings.
* Yoast SEO and RankMath integration (no duplicate tags).
* Admin settings page with Settings, Tools, and Diagnostics tabs.
* WP-CLI commands: `resolve` and `clear-cache`.
* REST API endpoints: `POST /resolve/{id}` and `GET /status/{id}`.
* Negative caching via transients (1-hour TTL) for failed URL checks.
* Multisite compatible.
= Previous versions =
This is the first release. For the full changelog, visit the [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/raw/branch/main/changelog.txt) file.
== Compliance ==
This plugin adheres to the following security measures and review protocols for each version:
* [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/)
* [WordPress Plugin Security](https://developer.wordpress.org/plugins/wordpress-org/plugin-security/)
* [WordPress APIs Security](https://developer.wordpress.org/apis/security/)
* [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards)
* [Plugin Check (PCP)](https://wordpress.org/plugins/plugin-check/)

45
robotstxt-og.php Normal file
View file

@ -0,0 +1,45 @@
<?php
/**
* Plugin Name: OpenGraph (by ROBOTSTXT)
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og
* Description: Intelligent Open Graph image fallback for social media crawlers. Automatically detects and serves compatible image formats (JPEG/PNG) when modern formats (AVIF/WebP) are used as featured images.
* Version: 1.0.0
* Requires at least: 6.7
* Requires PHP: 8.2
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* License: GPL v3 or later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Text Domain: robotstxt-og
* Domain Path: /languages
* Gitea Plugin URI: ROBOTSTXT/robotstxt-og
* Contributors: javiercasares, robotstxt
*
* @package ROBOTSTXT_OG
* @version 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// Define plugin constants.
define( 'ROBOTSTXT_OG_VERSION', '1.0.0' );
define( 'ROBOTSTXT_OG_PATH', plugin_dir_path( __FILE__ ) );
define( 'ROBOTSTXT_OG_URL', plugin_dir_url( __FILE__ ) );
define( 'ROBOTSTXT_OG_BASENAME', plugin_basename( __FILE__ ) );
// Initialize the updater system.
require_once ROBOTSTXT_OG_PATH . 'robotstxt-updater.php';
Robotstxt_Updater::init( __FILE__ );
// Load the main plugin class.
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-image-fallback.php';
// Initialize the plugin.
add_action(
'plugins_loaded',
function () {
Robotstxt_OG_Image_Fallback::get_instance()->init();
}
);

383
robotstxt-updater.php Normal file
View file

@ -0,0 +1,383 @@
<?php
/**
* Generic JSON-based updater for ROBOTSTXT plugins.
*
* This file is designed to be copied to any ROBOTSTXT plugin.
* It auto-configures itself by reading the plugin headers.
*
* @package ROBOTSTXT
* @version 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Class Robotstxt_Updater
*
* Generic updater that works with any plugin.
* Reads plugin headers and constructs update URL automatically.
*/
class Robotstxt_Updater {
/**
* Plugin file path.
*
* @var string
*/
private string $plugin_file_path;
/**
* Plugin basename (e.g., 'my-plugin/my-plugin.php').
*
* @var string
*/
private string $plugin_basename;
/**
* Plugin slug (directory name).
*
* @var string
*/
private string $plugin_slug;
/**
* Remote JSON URL.
*
* @var string
*/
private string $json_url;
/**
* Cache key.
*
* @var string
*/
private string $cache_key;
/**
* Plugin headers.
*
* @var array
*/
private array $plugin_data;
/**
* Initialize the updater.
*
* Usage in your main plugin file:
* require_once __DIR__ . '/robotstxt-updater.php';
* Robotstxt_Updater::init( __FILE__ );
*
* @param string $plugin_file_path Absolute path to the main plugin file.
*/
public static function init( string $plugin_file_path ): void {
$instance = new self( $plugin_file_path );
$instance->register();
}
/**
* Constructor.
*
* @param string $plugin_file_path Absolute path to the main plugin file.
*/
private function __construct( string $plugin_file_path ) {
$this->plugin_file_path = $plugin_file_path;
$this->plugin_basename = plugin_basename( $plugin_file_path );
$this->plugin_slug = dirname( $this->plugin_basename );
$this->plugin_data = $this->get_plugin_data();
$this->json_url = $this->build_json_url();
$this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename );
}
/**
* Register WordPress hooks.
*/
private function register(): void {
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) );
add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 );
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) );
}
/**
* Get plugin headers.
*
* @return array Plugin data.
*/
private function get_plugin_data(): array {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return get_plugin_data( $this->plugin_file_path, false, false );
}
/**
* Build JSON URL from plugin headers.
*
* Tries to use "Gitea Plugin URI" header to construct the URL.
* Falls back to Plugin URI if Gitea URI is not available.
*
* @return string JSON URL.
*/
private function build_json_url(): string {
// Try Gitea Plugin URI (format: "OWNER/REPO" or full URL).
if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) ) {
$gitea_uri = $this->plugin_data['Gitea Plugin URI'];
// If it's already a full URL, use it.
if ( str_starts_with( $gitea_uri, 'http' ) ) {
// Extract base URL and construct JSON path.
return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json';
}
// If it's in format "OWNER/REPO", construct full URL.
if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) {
return "https://git.robotstxt.es/{$gitea_uri}/raw/branch/main/update.json";
}
}
// Fallback: try to extract from Plugin URI.
if ( ! empty( $this->plugin_data['PluginURI'] ) ) {
$plugin_uri = $this->plugin_data['PluginURI'];
if ( str_contains( $plugin_uri, 'git.robotstxt.es' ) ) {
return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json';
}
}
// Last resort: construct from plugin slug.
return "https://git.robotstxt.es/ROBOTSTXT/{$this->plugin_slug}/raw/branch/main/update.json";
}
/**
* Inject update info into WP's plugin update transient.
*
* @param object|mixed $transient The update_plugins transient.
*
* @return object The modified transient.
*/
public function inject_update_info( $transient ) {
if ( ! is_object( $transient ) ) {
$transient = new stdClass();
}
if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) {
return $transient;
}
if ( empty( $transient->checked[ $this->plugin_basename ] ) ) {
return $transient;
}
$current_version = $transient->checked[ $this->plugin_basename ];
$remote = $this->get_remote_data();
if ( empty( $remote['version'] ) || empty( $remote['download_url'] ) ) {
return $transient;
}
if ( ! $this->is_compatible( $remote ) ) {
return $transient;
}
if ( version_compare( $remote['version'], $current_version, '>' ) ) {
$update = (object) array(
'slug' => $remote['slug'] ?? $this->plugin_slug,
'plugin' => $this->plugin_basename,
'new_version' => $remote['version'],
'url' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '',
'package' => $remote['download_url'],
'tested' => $remote['tested'] ?? '',
'requires' => $remote['requires'] ?? '',
'requires_php' => $remote['requires_php'] ?? '',
);
$transient->response[ $this->plugin_basename ] = $update;
}
return $transient;
}
/**
* Provide "View details" modal content.
*
* @param false|object|array $result The result object or array.
* @param string $action The type of information being requested.
* @param object $args Plugin API arguments.
*
* @return false|object The plugin information object or false.
*/
public function provide_plugin_details( $result, string $action, object $args ) {
if ( 'plugin_information' !== $action ) {
return $result;
}
if ( empty( $args->slug ) || $args->slug !== $this->plugin_slug ) {
return $result;
}
$remote = $this->get_remote_data();
if ( empty( $remote['version'] ) ) {
return $result;
}
return (object) array(
'name' => $remote['name'] ?? $this->plugin_data['Name'] ?? $this->plugin_slug,
'slug' => $remote['slug'] ?? $this->plugin_slug,
'version' => $remote['version'],
'author' => $remote['author'] ?? $this->plugin_data['Author'] ?? '',
'homepage' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '',
'requires' => $remote['requires'] ?? '',
'tested' => $remote['tested'] ?? '',
'requires_php' => $remote['requires_php'] ?? '',
'sections' => array(
'description' => $remote['description'] ?? $this->plugin_data['Description'] ?? '',
'changelog' => $remote['changelog'] ?? '',
),
'download_link' => $remote['download_url'] ?? '',
);
}
/**
* Get remote data with caching and HMAC signature verification.
*
* @return array Remote data.
*/
private function get_remote_data(): array {
$cached = get_site_transient( $this->cache_key );
// Verify HMAC signature if AUTH_SALT is defined and cache has signature.
if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) ) {
$expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $cached['data'] ), AUTH_SALT );
if ( hash_equals( $expected_sig, $cached['signature'] ) ) {
// Signature valid, return data.
return is_array( $cached['data'] ) ? $cached['data'] : array();
}
// Signature invalid, delete corrupted cache.
delete_site_transient( $this->cache_key );
$cached = false;
}
}
// If no valid cache, fetch fresh data.
if ( false === $cached ) {
$remote = $this->fetch_json();
// Store with HMAC signature if AUTH_SALT is available.
if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
$payload = array(
'data' => $remote ?: array(),
'timestamp' => time(),
'signature' => hash_hmac( 'sha256', $this->cache_key . serialize( $remote ?: array() ), AUTH_SALT ),
);
set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS );
} else {
// Fallback to standard caching.
set_site_transient( $this->cache_key, $remote ?: array(), 6 * HOUR_IN_SECONDS );
}
return is_array( $remote ) ? $remote : array();
}
// Legacy cache format without signature (backward compatibility).
return is_array( $cached ) ? $cached : array();
}
/**
* Fetch JSON from remote URL.
*
* @return array Decoded JSON data.
*/
private function fetch_json(): array {
$response = wp_remote_get(
$this->json_url,
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
),
)
);
if ( is_wp_error( $response ) ) {
return array();
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return array();
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
return is_array( $data ) ? $data : array();
}
/**
* Check compatibility.
*
* @param array $remote Remote data.
*
* @return bool True if compatible.
*/
private function is_compatible( array $remote ): bool {
if ( ! empty( $remote['requires_php'] ) ) {
if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) {
return false;
}
}
if ( ! empty( $remote['requires'] ) ) {
if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) {
return false;
}
}
return true;
}
/**
* Handle manual cache clear via URL parameter.
*/
public function handle_cache_clear(): void {
// Check if this is a cache clear request first.
$clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW );
if ( null === $clear_cache ) {
return;
}
// This is a cache clear request - now verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'update_plugins' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
$this->clear_cache();
wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) );
exit;
}
/**
* Clear update cache.
*/
public function clear_cache(): void {
delete_site_transient( $this->cache_key );
delete_site_transient( 'update_plugins' );
}
}
}

120
uninstall.php Normal file
View file

@ -0,0 +1,120 @@
<?php
/**
* Uninstall handler for OpenGraph (by ROBOTSTXT)
*
* Fired when the plugin is uninstalled.
*
* @package ROBOTSTXT_OG
* @version 1.0.0
*/
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit; // Exit if accessed directly.
}
/**
* Delete plugin data if user opted in to data deletion.
*/
function robotstxt_og_uninstall_cleanup() {
// Check if user opted in to delete data on uninstall.
$delete_data = get_option( 'robotstxt_og_delete_data_on_uninstall', false );
if ( ! $delete_data ) {
// Preserve data by default.
return;
}
// Delete all plugin options.
delete_option( 'robotstxt_og_fallback_image' );
delete_option( 'robotstxt_og_homepage_image' );
delete_option( 'robotstxt_og_enable_facebook' );
delete_option( 'robotstxt_og_enable_twitter' );
delete_option( 'robotstxt_og_twitter_card_type' );
delete_option( 'robotstxt_og_twitter_site' );
delete_option( 'robotstxt_og_delete_data_on_uninstall' );
// Delete all cached postmeta (fallback URLs and per-post OG overrides).
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Bulk delete during uninstall — no WP API exists to delete all postmeta rows by key at once.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_image_fallback_url'
)
);
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_title'
)
);
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_description'
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Clear related transients.
delete_transient( 'robotstxt_og_diagnostics_cache' );
// On Multisite, clean up site-by-site.
if ( is_multisite() ) {
$sites = get_sites(
array(
'number' => 0,
)
);
foreach ( $sites as $site ) {
switch_to_blog( $site->blog_id );
// Delete site-specific options.
delete_option( 'robotstxt_og_fallback_image' );
delete_option( 'robotstxt_og_homepage_image' );
delete_option( 'robotstxt_og_enable_facebook' );
delete_option( 'robotstxt_og_enable_twitter' );
delete_option( 'robotstxt_og_twitter_card_type' );
delete_option( 'robotstxt_og_twitter_site' );
delete_option( 'robotstxt_og_delete_data_on_uninstall' );
// Delete site-specific postmeta.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Bulk delete during uninstall — no WP API exists to delete all postmeta rows by key at once.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_image_fallback_url'
)
);
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_title'
)
);
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_description'
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Clear site-specific transients.
delete_transient( 'robotstxt_og_diagnostics_cache' );
restore_current_blog();
}
}
}
// Execute cleanup.
robotstxt_og_uninstall_cleanup();

27
update.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "OpenGraph (by ROBOTSTXT)",
"slug": "robotstxt-og",
"version": "1.0.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/download/1.0.0/robotstxt-og-1.0.0.zip",
"requires": "6.7",
"requires_php": "8.2",
"tested": "6.9",
"last_updated": "2026-02-18",
"author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og",
"description": "Intelligent Open Graph meta tags with automatic image fallback from AVIF/WebP to JPEG/PNG for social media crawlers. Outputs a complete set of og:* and twitter:* meta tags, detects incompatible image formats, and resolves JPEG/PNG alternatives via HTTP HEAD requests.",
"changelog": "<h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li><li><strong>Added:</strong> Full Open Graph meta tags: og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, og:image:alt.</li><li><strong>Added:</strong> Article tags for singular posts: article:published_time, article:modified_time, article:section, article:tag.</li><li><strong>Added:</strong> Twitter Card tags: twitter:card, twitter:site, twitter:image.</li><li><strong>Added:</strong> Automatic detection and HTTP HEAD-based resolution of JPEG/PNG alternatives for AVIF, WebP, GIF, BMP, SVG, and TIFF images.</li><li><strong>Added:</strong> Postmeta caching with automatic invalidation on featured image change.</li><li><strong>Added:</strong> Per-post OG title and description overrides via post editor meta box.</li><li><strong>Added:</strong> Global fallback image URL and homepage-specific image URL settings.</li><li><strong>Added:</strong> Yoast SEO and RankMath integration (no duplicate tags).</li><li><strong>Added:</strong> Admin settings page with Settings, Tools, and Diagnostics tabs.</li><li><strong>Added:</strong> WP-CLI commands: wp og-fallback resolve and wp og-fallback clear-cache.</li><li><strong>Added:</strong> REST API: POST /wp-json/robotstxt-og/v1/resolve/{post_id} and GET /wp-json/robotstxt-og/v1/status/{post_id}.</li><li><strong>Added:</strong> Developer filters: robotstxt_og_external_image_enabled, robotstxt_og_external_image_timeout, robotstxt_og_taxonomy_image, robotstxt_og_enable_logging.</li><li><strong>Added:</strong> Multisite compatible, full i18n support, Spanish (es_ES) translation included.</li><li><strong>Security:</strong> Nonce verification, manage_options capability checks, input sanitization, output escaping, prepared SQL statements throughout.</li></ul>",
"sections": {
"description": "<p>Social media crawlers &#8212; Facebook, Twitter/X, LinkedIn, WhatsApp &#8212; do not support modern image formats such as AVIF and WebP. When a post&#8217;s featured image uses one of these formats, the platform shows a broken or missing image preview.</p><p><strong>OpenGraph (by ROBOTSTXT)</strong> outputs a complete set of Open Graph and Twitter Card meta tags and solves this automatically. It detects whether each post&#8217;s featured image is in a supported format and, when needed, resolves and caches a compatible JPEG or PNG alternative.</p><ul><li>Full og:* and twitter:* meta tag output</li><li>Automatic AVIF/WebP/GIF/BMP/SVG/TIFF detection with JPEG/PNG fallback</li><li>Per-post OG title and description overrides via post editor meta box</li><li>Global fallback image URL and homepage-specific image URL</li><li>Yoast SEO and RankMath integration (no duplicate tags)</li><li>Postmeta caching with automatic invalidation</li><li>Admin panel with Settings, Tools, and Diagnostics tabs</li><li>WP-CLI commands and REST API endpoints</li><li>Multisite compatible</li></ul>",
"changelog": "<h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li><li><strong>Added:</strong> Full Open Graph meta tags: og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, og:image:alt.</li><li><strong>Added:</strong> Article tags for singular posts: article:published_time, article:modified_time, article:section, article:tag.</li><li><strong>Added:</strong> Twitter Card tags: twitter:card, twitter:site, twitter:image.</li><li><strong>Added:</strong> Automatic detection and HTTP HEAD-based resolution of JPEG/PNG alternatives for AVIF, WebP, GIF, BMP, SVG, and TIFF images.</li><li><strong>Added:</strong> Postmeta caching with automatic invalidation on featured image change.</li><li><strong>Added:</strong> Per-post OG title and description overrides via post editor meta box.</li><li><strong>Added:</strong> Global fallback image URL and homepage-specific image URL settings.</li><li><strong>Added:</strong> Yoast SEO and RankMath integration (no duplicate tags).</li><li><strong>Added:</strong> Admin settings page with Settings, Tools, and Diagnostics tabs.</li><li><strong>Added:</strong> WP-CLI commands: wp og-fallback resolve and wp og-fallback clear-cache.</li><li><strong>Added:</strong> REST API: POST /wp-json/robotstxt-og/v1/resolve/{post_id} and GET /wp-json/robotstxt-og/v1/status/{post_id}.</li><li><strong>Added:</strong> Developer filters: robotstxt_og_external_image_enabled, robotstxt_og_external_image_timeout, robotstxt_og_taxonomy_image, robotstxt_og_enable_logging.</li><li><strong>Added:</strong> Multisite compatible, full i18n support, Spanish (es_ES) translation included.</li><li><strong>Security:</strong> Nonce verification, manage_options capability checks, input sanitization, output escaping, prepared SQL statements throughout.</li></ul>"
},
"banners": {
"low": "",
"high": ""
},
"icons": {
"1x": "",
"2x": ""
}
}