*/ public const MARKERS = [ 'generated by Acid AEO', '"generator": "Acid AEO', ]; /** * Every file that may be written, in writing order. * * `path` is the fallback used when the file's module is switched off and * has therefore registered nothing with `Endpoints\Router`; a registered * file's own path wins, so a module that ever moves its file moves the * physical copy with it. `module` is the settings section holding the * `enabled` flag of whatever renders the body. * * `robots.txt` is deliberately absent and must stay absent: it is written * by WordPress core through a filter every plugin on the site can join, so * a physical copy would freeze one moment of a file that has many authors. * * @var array> */ private const FILES = [ 'llms' => [ 'path' => 'llms.txt', 'module' => 'llms', 'class' => LlmsTxt::class, 'method' => 'render', ], 'llms_full' => [ 'path' => 'llms-full.txt', 'module' => 'llms', 'class' => LlmsTxt::class, 'method' => 'render_full', ], 'humans' => [ 'path' => 'humans.txt', 'module' => 'humans', 'class' => HumansTxt::class, 'method' => 'render', ], 'ai_txt' => [ 'path' => 'ai.txt', 'module' => 'ai_txt', 'class' => AiTxt::class, 'method' => 'render', ], 'ai_context' => [ 'path' => 'ai-context.json', 'module' => 'ai_context', 'class' => AiContext::class, 'method' => 'render', ], 'security' => [ 'path' => '.well-known/security.txt', 'module' => 'security', 'class' => SecurityTxt::class, 'method' => 'render', ], ]; /** * How long a write waits for the changes that follow it, in seconds. * * Saving the settings, publishing a post and deleting one all change what * the files say, and an editor doing any of them does several in a row. * One scheduled event per minute renders each file once instead of once * per keystroke. * * @var int */ private const DEBOUNCE = 60; /** * How much of a large file is read when looking for the marker. * * The signature sits at the top of `ai-context.json` and at the bottom of * `llms.txt`, so both ends are read; `llms-full.txt` can be megabytes, and * loading it into memory to answer a yes or no question is not worth it. * * @var int */ private const MARKER_SPAN = 8192; /** * What this request found out about writing, if it was allowed to ask. * * Null means "not asked", which is also what a front-end request always * gets. See `writable()`. * * @var bool|null */ private static ?bool $writable = null; /** * Forgets what this request found out about writing. For tests. * * @return void */ public static function reset(): void { self::$writable = null; } /** * Machine name, matching the settings section the module reads. * * @return string Module slug. */ public function slug(): string { return 'static_files'; } /** * Human readable, translated module name. * * @return string Module title. */ public function title(): string { return __( 'Static files', 'acid-aeo' ); } /** * Hooks the module into WordPress. * * Called only while the module is enabled, so nothing here has to ask * whether it should be running. * * The last block catches the one transition `on_settings_updated()` cannot * see. When the files are switched on, the module that was booted for that * request was the switched off one, so it had no hook attached when the * settings were saved; the next request boots it enabled and finds the * marker option missing, which is what "switched on since last time" looks * like from here. It publishes on `wp_loaded` rather than here, because the * renderers read post types and taxonomies that only exist after `init` — * and only on a back office or CLI request, because rendering every file * is not something a visitor should ever wait for. The queued event is the * fallback for a site whose files were switched on without anybody * afterwards opening wp-admin. * * Nothing in here records the module as active: `write_all()` does that, * once it has actually written something. Setting the flag at boot would * make the flip look as though it had already been handled, and the files * would never appear. * * Neither the queued event nor the `wp_loaded` write is set up on a host * where `WP_Filesystem` is not `direct`. Nothing there can be written by * something with nobody to ask for credentials, so the flag would never be * set, and this block would queue a write that fails on every request from * now until somebody changes the host. The `static_filesystem` check in the * audit is what reports that, once, where it can be acted on. * * A front-end request does none of it. It leaves with four hooks attached * and nothing else done: `writable()` refuses to probe there, so a visitor * never pays for `WP_Filesystem()`, and `true !== null` sends this method * home. The write itself happens in the back office, in WP-CLI or on cron, * all of which may ask. * * @return void */ public function register(): void { add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); add_action( 'save_post', [ $this, 'schedule_for_post' ], 10, 2 ); add_action( 'deleted_post', [ $this, 'schedule_for_post' ], 10, 2 ); add_action( 'acid_aeo_daily_tasks', [ $this, 'write_all' ] ); add_action( self::CRON_HOOK, [ $this, 'write_all' ] ); if ( self::shares_the_webroot() || self::was_active() || true !== self::writable() ) { return; } $this->schedule(); if ( is_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { add_action( 'wp_loaded', [ $this, 'write_all' ] ); } } /** * Whether this request can write, when this request is allowed to ask. * * `Support\Filesystem::direct()` loads `wp-admin/includes/file.php` and * makes WordPress choose a transport. That is a reasonable cost in the back * office, in WP-CLI and on cron — the three places that actually write — * and an unreasonable one on a page a visitor is waiting for. So a * front-end request is not allowed to ask, and gets null: "not known * here", which callers read as "carry on and let the cron event find out". * * Asked at most once per request; `reset()` clears the answer for tests. * * @return bool|null True or false where the probe is allowed, null where it is not. */ private static function writable(): ?bool { if ( ! is_admin() && ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! wp_doing_cron() ) { return null; } if ( null === self::$writable ) { self::$writable = null !== Filesystem::direct(); } return self::$writable; } /** * Reacts to a settings save, including to being switched off by one. * * Switching the module off is the only moment the files can be removed * safely: after it, the module is not booted at all. So the flip is caught * here, on the request that saved it, while this instance is still hooked * in — and only the files carrying the marker are removed, so a webroot * that had an `llms.txt` before the plugin arrived still has it after. * * Public because WordPress invokes hook callbacks from outside the class. * * @param string $section Settings section that changed, or `*` for all of them. * @return void */ public function on_settings_updated( string $section = '*' ): void { parent::on_settings_updated( $section ); $own = ( '' === $section || '*' === $section || $this->slug() === $section ); $enabled = $this->is_enabled(); $active = self::was_active(); if ( $own && ! $enabled ) { if ( $active ) { $this->remove_all(); } self::remember_active( false ); return; } if ( ! $enabled ) { return; } if ( $own && ! $active ) { // Switched on by this very save: publish now rather than in a // minute, so the Tools screen that reloads after it can already // link to files that exist. `write_all()` records the flag. $this->write_all(); return; } $this->schedule(); } /** * Queues a write after a post that appears in a generated file changed. * * Public because WordPress invokes hook callbacks from outside the class. * * @param mixed $post_id Post ID. * @param mixed $post Post object, as `save_post` and `deleted_post` pass it. * @return void */ public function schedule_for_post( $post_id, $post = null ): void { if ( $this->is_listed_post( (int) $post_id, $post ) ) { $this->schedule(); } } /** * Queues one write for a minute from now, unless one is already queued. * * Skipped where the answer is already known to be no — a back office save * on a host that would ask for FTP credentials queues nothing, rather than * queueing an event that can only fail. A front-end save (the REST editor, * a front-end form) does not know, because it may not ask; it queues, and * the cron event that runs a minute later finds out for itself in * `write_all()`, where the failure is recorded rather than retried. * * @return void */ public function schedule(): void { if ( false === self::writable() ) { return; } if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_single_event( time() + self::DEBOUNCE, self::CRON_HOOK ); } } /** * Writes every selected file to disk. * * Public because it is the module's whole job: the CLI, the REST tools * route, the one-click fix, the daily task and the debounced cron event * all call it. * * Also prunes: a file this plugin wrote that is no longer wanted — because * it was deselected, or because whatever renders it was switched off — is * deleted and reported under `removed`. Without that step, deselecting a * file would leave the last version of it on disk forever, and the web * server would go on answering with it: the worst of both worlds, since * the virtual file meant to replace it would never be reached. * * Records the module's state on the way out, so that a write started from * the CLI or from a one-click fix leaves the same trail a settings save * would — and a later switch off knows there is something to clean up. * * @return array Keys `written` and `removed` (lists of paths), `skipped` * and `errors` (path => reason). Paths are relative to the * WordPress root. */ public function write_all(): array { $result = [ 'written' => [], 'removed' => [], 'skipped' => [], 'errors' => [], ]; if ( self::shares_the_webroot() ) { $result['errors'][ Filesystem::webroot() ] = self::network_reason(); return $result; } $filesystem = Filesystem::direct(); if ( null === $filesystem ) { $result['errors'][ Filesystem::webroot() ] = Filesystem::reason(); return $result; } $targets = self::targets( self::selected_files(), Router::files() ); foreach ( $targets as $slug => $relative ) { try { $outcome = self::write_one( $filesystem, (string) $slug, $relative ); } catch ( Throwable $error ) { $outcome = [ 'status' => 'errors', 'reason' => $error->getMessage(), ]; } if ( 'written' === $outcome['status'] ) { $result['written'][] = $relative; continue; } $result[ $outcome['status'] ][ $relative ] = $outcome['reason']; } foreach ( self::stale( self::marked_on_disk(), self::wanted( $targets ) ) as $relative ) { if ( $filesystem->delete( Filesystem::webroot() . $relative ) ) { $result['removed'][] = $relative; continue; } $result['errors'][ $relative ] = __( 'no longer wanted, but could not be deleted', 'acid-aeo' ); } self::remember_active( $this->is_enabled() ); return $result; } /** * The subset of a target list whose bodies can actually be published. * * A file whose module is switched off is still a target — `write_all()` * reports it as skipped rather than losing track of where it would go — * but it is not wanted, so a copy of it on disk is stale. * * @param array $targets Slug => relative path. * @return array Slug => relative path. */ private static function wanted( array $targets ): array { $wanted = []; foreach ( $targets as $slug => $relative ) { if ( self::renderer_enabled( (string) $slug ) ) { $wanted[ $slug ] = $relative; } } return $wanted; } /** * Whether this site would be writing into a webroot it does not own. * * A multisite network is one WordPress, one directory and one `/llms.txt`, * however many sites answer from it. Only the main site's copy can be the * one served at the network's root, so only the main site writes it. The * marker is no defence here: every site's file is signed the same way, so an * overwrite would look entirely legitimate to the guard that exists. * * Virtual delivery is unaffected — each site keeps answering its own * `/llms.txt` through the rewrite rules, which is why this is a refusal to * write rather than a refusal to run. * * @return bool True on a network site that is not the main one. */ public static function shares_the_webroot(): bool { if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { return false; } return ! function_exists( 'is_main_site' ) || ! is_main_site(); } /** * Why nothing is written on a network site, in one sentence. * * @return string Translated explanation. */ private static function network_reason(): string { return __( 'Every site on this network is served from one directory, so a copy written here would replace the main site\'s file and publish this site\'s content at the network\'s address. The files stay virtual on network sites; switch them on from the main site to put them on disk.', 'acid-aeo' ); } /** * Every file this module can write that is on disk and signed by it. * * @return array Slug => relative path. */ private static function marked_on_disk(): array { $marked = []; foreach ( self::targets( array_keys( self::FILES ), Router::files() ) as $slug => $relative ) { if ( self::is_written( Filesystem::webroot() . $relative ) ) { $marked[ $slug ] = $relative; } } return $marked; } /** * Deletes every file this plugin wrote to disk. * * Every file the module can write is considered, not only the ones * currently selected: the selection may have changed since they were * written, and a file left behind because it was deselected first is * exactly the file nobody would think to delete by hand. The marker keeps * that safe — a path that is not signed is skipped, whatever it is called. * * @return array Keys `removed` (list of paths), `skipped` (path => reason) * and `errors` (path => reason). Paths are relative to the * WordPress root. */ public function remove_all(): array { $result = [ 'removed' => [], 'skipped' => [], 'errors' => [], ]; // The files in a shared webroot were written by the main site, and this // is not it. Skipped rather than failed: there is nothing here to clean // up, which is the state a switch off is asking for. if ( self::shares_the_webroot() ) { $result['skipped'][ Filesystem::webroot() ] = self::network_reason(); return $result; } $filesystem = Filesystem::direct(); if ( null === $filesystem ) { $result['errors'][ Filesystem::webroot() ] = Filesystem::reason(); return $result; } foreach ( self::targets( array_keys( self::FILES ), Router::files() ) as $relative ) { $path = Filesystem::webroot() . $relative; if ( ! $filesystem->exists( $path ) ) { $result['skipped'][ $relative ] = __( 'not on disk', 'acid-aeo' ); continue; } $existing = $filesystem->get_contents( $path ); if ( ! is_string( $existing ) ) { $result['errors'][ $relative ] = __( 'the file could not be read', 'acid-aeo' ); continue; } if ( ! self::is_ours( $existing ) ) { $result['skipped'][ $relative ] = __( 'not written by Acid AEO', 'acid-aeo' ); continue; } if ( ! $filesystem->delete( $path ) ) { $result['errors'][ $relative ] = __( 'the file could not be deleted', 'acid-aeo' ); continue; } $result['removed'][] = $relative; } return $result; } /** * Whether a body was written by this plugin. * * Pure, and the one rule that keeps the module from touching somebody * else's files. * * @param string $content File contents, or enough of them to hold the marker. * @return bool True when the content carries one of `MARKERS`. */ public static function is_ours( string $content ): bool { foreach ( self::MARKERS as $marker ) { if ( false !== strpos( $content, $marker ) ) { return true; } } return false; } /** * The files to write, as slug => path relative to the WordPress root. * * Pure: everything it needs is passed in, so the mapping — including the * refusal to write `robots.txt` — can be tested without a filesystem. * * A slug this module does not own is dropped, which is what keeps the * settings screen honest: it offers every file `Endpoints\Router` knows, * and that list also holds the IndexNow key file and the root copy of * `security.txt`, neither of which belongs on disk. * * @param array $files Selected slugs, from the settings. An empty * selection means every file the site publishes. * @param array $router_files Registered files, from `Router::files()`. * @return array Slug => relative path, in writing order. */ public static function targets( array $files, array $router_files ): array { $wanted = []; foreach ( $files as $slug ) { $slug = is_scalar( $slug ) ? (string) $slug : ''; if ( isset( self::FILES[ $slug ] ) ) { $wanted[ $slug ] = true; } } if ( [] === $wanted ) { foreach ( array_keys( self::FILES ) as $slug ) { if ( isset( $router_files[ $slug ] ) ) { $wanted[ $slug ] = true; } } } $targets = []; foreach ( array_keys( self::FILES ) as $slug ) { if ( ! isset( $wanted[ $slug ] ) ) { continue; } $path = self::relative_path( $slug, $router_files ); if ( '' !== $path ) { $targets[ $slug ] = $path; } } return $targets; } /** * The files on disk that are ours but are no longer wanted. * * Pure, like `targets()` and for the same reason: deleting somebody's file * is the operation that has to be right, so the decision to delete one is * kept apart from the act of doing it. * * Compared by path rather than by slug, so a file whose module moved it * leaves no orphan behind at the old address. * * @param array $on_disk_marked Slug => relative path of the marked files found. * @param array $targets Slug => relative path of the files wanted now. * @return array Slug => relative path, for the ones to delete. */ public static function stale( array $on_disk_marked, array $targets ): array { $wanted = []; foreach ( $targets as $relative ) { if ( is_scalar( $relative ) && '' !== (string) $relative ) { $wanted[ (string) $relative ] = true; } } $stale = []; foreach ( $on_disk_marked as $slug => $relative ) { $relative = is_scalar( $relative ) ? (string) $relative : ''; if ( '' === $relative || isset( $wanted[ $relative ] ) ) { continue; } $stale[ (string) $slug ] = $relative; } return $stale; } /** * The files a site may choose to have written, as slug => public path. * * The settings screen and the wizard both offer this list, and it is not * `Endpoints\Router::files()`: that one also holds the IndexNow key file * and the root copy of `security.txt`, neither of which this module writes. * Offering a choice that would then be ignored is worse than not offering * it, so what can be written is answered by the thing that writes it. * * @return array Slug => path relative to the WordPress root. */ public static function choices(): array { $registered = Router::files(); $choices = []; foreach ( self::targets( array_keys( self::FILES ), $registered ) as $slug => $relative ) { if ( isset( $registered[ $slug ] ) ) { $choices[ $slug ] = $relative; } } return $choices; } /** * Audit checks contributed by this module. * * The disabled case is answered here rather than left to `Audit\Runner`, * which would warn: a site with no CDN in front of it needs no physical * copies, and telling it that it is missing something would be wrong. A * skip is left out of the score entirely, so the default costs nothing. * * @return array Checks. */ public function checks(): array { if ( ! $this->is_enabled() ) { return [ Check::skip( 'static_files_disabled', $this->slug(), __( 'Physical copies of the generated files', 'acid-aeo' ), __( 'Optional: physical files are not needed unless a CDN/static host bypasses WordPress.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ), ]; } return [ $this->filesystem_check(), $this->written_check(), ]; } /** * Whether WordPress can write to the webroot without asking for credentials. * * @return Check The finding. */ private function filesystem_check(): Check { $id = 'static_filesystem'; $label = __( 'Filesystem access', 'acid-aeo' ); if ( null !== Filesystem::direct() ) { return Check::pass( $id, $this->slug(), $label, __( 'WordPress writes to this webroot directly, so the generated files can be saved there.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } return Check::fail( $id, $this->slug(), $label, Filesystem::reason(), Check::WEIGHT_IMPORTANT ); } /** * Whether every selected file is on disk and signed by this plugin. * * A file that is ours but is no longer wanted is normally not a finding at * all: the next write prunes it, and the next write is at most a day away. * It becomes one only when the prune cannot run — a webroot WordPress can * no longer write to keeps serving a file the settings say to stop * publishing, and nothing else on this screen would say so. * * @return Check The finding. */ private function written_check(): Check { $id = 'static_written'; $label = __( 'Generated files are on disk', 'acid-aeo' ); $targets = self::targets( self::selected_files(), Router::files() ); $wanted = self::wanted( $targets ); if ( [] === $targets ) { return Check::skip( $id, $this->slug(), $label, __( 'No file is selected to be written to disk, so there is nothing to look for.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } $marked = self::marked_on_disk(); $missing = []; foreach ( $wanted as $slug => $relative ) { if ( ! isset( $marked[ $slug ] ) ) { $missing[] = $relative; } } if ( [] !== $missing ) { return Check::fail( $id, $this->slug(), $label, sprintf( /* translators: %s: comma separated list of file paths. */ __( 'These files are selected but are not on disk, or the copy on disk was written by something else: %s', 'acid-aeo' ), implode( ', ', $missing ) ), Check::WEIGHT_IMPORTANT, 'write_static_files' ); } $stale = self::stale( $marked, $wanted ); if ( [] !== $stale && null === Filesystem::direct() ) { return Check::warn( $id, $this->slug(), $label, sprintf( /* translators: %s: comma separated list of file paths. */ __( 'These files are no longer published but are still on disk, and they cannot be removed while the webroot is not writable: %s', 'acid-aeo' ), implode( ', ', $stale ) ), Check::WEIGHT_IMPORTANT, 'write_static_files' ); } return Check::pass( $id, $this->slug(), $label, __( 'Every selected file is on disk and carries the Acid AEO marker.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } /** * Writes one file, and says what happened to it. * * @param object $filesystem The `WP_Filesystem_Base` implementation in use. * @param string $slug File slug, a key of `FILES`. * @param string $relative Path relative to the WordPress root. * @return array Keys `status` (`written`, `skipped` or `errors`) and `reason`. */ private static function write_one( object $filesystem, string $slug, string $relative ): array { $path = Filesystem::webroot() . $relative; if ( ! self::renderer_enabled( $slug ) ) { return self::outcome( 'skipped', __( 'module disabled', 'acid-aeo' ) ); } $body = self::render( $slug ); if ( '' === $body ) { return self::outcome( 'errors', __( 'the file could not be generated', 'acid-aeo' ) ); } if ( $filesystem->exists( $path ) ) { $existing = $filesystem->get_contents( $path ); if ( ! is_string( $existing ) ) { return self::outcome( 'errors', __( 'the file on disk could not be read', 'acid-aeo' ) ); } if ( ! self::is_ours( $existing ) ) { return self::outcome( 'skipped', __( 'exists and was not written by Acid AEO', 'acid-aeo' ) ); } if ( $existing === $body ) { return self::outcome( 'skipped', __( 'already up to date', 'acid-aeo' ) ); } } elseif ( ! self::ensure_directory( $filesystem, dirname( $path ) ) ) { return self::outcome( 'errors', __( 'the directory could not be created', 'acid-aeo' ) ); } if ( ! $filesystem->put_contents( $path, $body, Filesystem::file_mode() ) ) { return self::outcome( 'errors', __( 'the file could not be written', 'acid-aeo' ) ); } return self::outcome( 'written', '' ); } /** * One outcome of `write_one()`. * * @param string $status One of `written`, `skipped` or `errors`. * @param string $reason Translated explanation, empty for a write. * @return array The outcome. */ private static function outcome( string $status, string $reason ): array { return [ 'status' => $status, 'reason' => $reason, ]; } /** * Makes sure the directory a file goes into exists. * * Only `.well-known/` ever needs creating; every other file sits in the * WordPress root, which exists by definition. * * @param object $filesystem The `WP_Filesystem_Base` implementation in use. * @param string $directory Absolute directory path. * @return bool True when the directory exists afterwards. */ private static function ensure_directory( object $filesystem, string $directory ): bool { if ( $filesystem->is_dir( $directory ) ) { return true; } return (bool) $filesystem->mkdir( $directory, Filesystem::dir_mode() ); } /** * The body of one file, rendered fresh. * * The module is instantiated rather than taken from `Plugin::modules()` * for the same reason the preview does it: rendering does not depend on a * module having registered anything, and a booted instance may not exist. * * @param string $slug File slug, a key of `FILES`. * @return string The body, or an empty string when this build cannot render it. */ private static function render( string $slug ): string { $class_name = (string) ( self::FILES[ $slug ]['class'] ?? '' ); $method = (string) ( self::FILES[ $slug ]['method'] ?? '' ); if ( ! class_exists( $class_name ) || ! method_exists( $class_name, $method ) ) { return ''; } $module = new $class_name(); $body = $module->$method(); return is_string( $body ) ? $body : ''; } /** * Whether whatever renders a file is switched on. * * A file nobody publishes must not be written to disk: the physical copy * would outlive the decision to stop publishing it, and would then be the * only version of it anybody could see. * * @param string $slug File slug, a key of `FILES`. * @return bool True when the body can be published. */ private static function renderer_enabled( string $slug ): bool { $section = (string) ( self::FILES[ $slug ]['module'] ?? '' ); if ( '' === $section || ! (bool) Options::get( $section . '.enabled', true ) ) { return false; } return 'llms_full' !== $slug || (bool) Options::get( 'llms.full_enabled', true ); } /** * Path of one file relative to the WordPress root. * * @param string $slug File slug, a key of `FILES`. * @param array $router_files Registered files, from `Router::files()`. * @return string Relative path, or an empty string when the path may not be written. */ private static function relative_path( string $slug, array $router_files ): string { $registered = $router_files[ $slug ]['path'] ?? ''; $path = is_string( $registered ) && '' !== $registered ? $registered : (string) self::FILES[ $slug ]['path']; $path = ltrim( str_replace( '\\', '/', $path ), '/' ); if ( '' === $path || false !== strpos( $path, '../' ) ) { return ''; } // Belt and braces: the allow list above cannot name robots.txt, and a // module cannot register its file under that path either — but a file // core owns is worth refusing twice. return 'robots.txt' === strtolower( basename( $path ) ) ? '' : $path; } /** * The files the settings ask for. * * @return array Selected slugs, empty when the site chose none. */ private static function selected_files(): array { $files = Options::get( 'static_files.files', [] ); return is_array( $files ) ? array_values( $files ) : []; } /** * Whether a path holds a file this plugin wrote. * * Reads with plain PHP rather than through `WP_Filesystem`: an audit runs on * a page load and must never end up asking for FTP credentials. * * Both ends are read because the signature sits at whichever end suits the * format — `security.txt` opens with it, `llms.txt` closes with it — and the * file between them can be megabytes long. Public so that the audit's * conflict check asks the same question in the same way; asking it of only * one end is how a file this plugin wrote gets reported as somebody else's. * * @param string $path Absolute path. * @return bool True when the file exists and carries the marker. */ public static function is_written( string $path ): bool { if ( ! is_file( $path ) || ! is_readable( $path ) ) { return false; } $size = (int) filesize( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a known local file to identify who wrote it; WP_Filesystem would ask for credentials, which an audit must never do. $head = (string) file_get_contents( $path, false, null, 0, self::MARKER_SPAN ); if ( $size <= self::MARKER_SPAN ) { return self::is_ours( $head ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- See above; the signature of llms.txt is on its last line, and that file can be megabytes long. $tail = (string) file_get_contents( $path, false, null, $size - self::MARKER_SPAN, self::MARKER_SPAN ); return self::is_ours( $head ) || self::is_ours( $tail ); } /** * Whether the files were published the last time the module looked. * * @return bool True when the module considers itself active. */ private static function was_active(): bool { return (bool) get_option( self::ACTIVE_OPTION, false ); } /** * Records whether the files are published. * * Not autoloaded: it is read once per request by a module most sites never * switch on, and written only when the answer changes. * * @param bool $active True when the files are published. * @return void */ private static function remember_active( bool $active ): void { if ( $active ) { update_option( self::ACTIVE_OPTION, 1, false ); return; } delete_option( self::ACTIVE_OPTION ); } }