From 7204e1dfa60147fba1bd74d1b51deb749f2e0da6 Mon Sep 17 00:00:00 2001 From: Lukce Date: Thu, 24 Sep 2026 02:03:50 -0400 Subject: [PATCH 1/2] WordPress plugin: serve llms.txt from the site's own pages WordPress is the one surface in this plan with a plausible path to four figures, because its plugin directory is a search surface with its own SEO: people find plugins by typing into wp-admin, not by reading our docs. It SERVES /llms.txt rather than writing one. Every other generator writes the file to disk once, which is wrong twice over here: plenty of hosts have a read-only or inconsistently writable web root, and a file written today is stale the moment somebody publishes a page. This answers a rewrite rule from the posts table, cached in a transient that drops on any publish, edit or delete. A real file at the web root still wins, because rewrite rules only run when WordPress does, so a site already publishing its own keeps it. It will not invent facts. An empty About produces a TODO, not generated prose: a machine reading that states something the site does not support is worse than none, because a model repeats it. Privacy policy, password-protected content and untitled pages are all excluded, so the file only ever contains what is already public. The :defined guard is enqueued by the plugin rather than documented, which is the one thing a WordPress user cannot reasonably be expected to get right by hand. The script tag is version-pinned with an integrity hash, added through a script_loader_tag filter scoped to our handle, since WordPress has no SRI API. That also forces ver => null: a ?ver= query string would break the hash match. Verification is two layers, because linting only proves it parses. A stub of the fourteen WordPress functions the generator touches lets the generator actually run, and 14 assertions cover the exclusions, the TODO fallback, tag stripping and the page cap. CI lints at PHP 7.4, the declared floor, not at whatever the runner ships: syntax that is fine on 8.x and fatal on 7.4 is exactly the failure a user hits and we would not. Not submitted to the directory. That is a manual review queue and then SVN, and the screenshots readme.txt references still need taking against a real install. --- .github/workflows/ci.yml | 19 ++ package.json | 3 +- wordpress/README.md | 62 +++++ .../human-machine-view/human-machine-view.php | 239 ++++++++++++++++++ .../includes/class-hmv-generator.php | 216 ++++++++++++++++ .../includes/class-hmv-settings.php | 226 +++++++++++++++++ wordpress/human-machine-view/readme.txt | 92 +++++++ wordpress/test/generator-test.php | 136 ++++++++++ 8 files changed, 992 insertions(+), 1 deletion(-) create mode 100644 wordpress/README.md create mode 100644 wordpress/human-machine-view/human-machine-view.php create mode 100644 wordpress/human-machine-view/includes/class-hmv-generator.php create mode 100644 wordpress/human-machine-view/includes/class-hmv-settings.php create mode 100644 wordpress/human-machine-view/readme.txt create mode 100644 wordpress/test/generator-test.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d95c4c..713bbd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,3 +14,22 @@ jobs: with: node-version: 20 - run: npm test + + wordpress: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "7.4" + coverage: none + # 7.4 is the plugin's declared floor, so lint at the floor: syntax that is fine on 8.x + # and fatal on 7.4 is exactly the failure a WordPress user would hit and we would not. + - name: Lint at the declared PHP floor + run: | + for f in $(find wordpress -name '*.php'); do + php -l "$f" + done + # Linting proves it parses. This proves the generator produces a sensible machine + # reading, against a thin stub of WordPress. + - run: php wordpress/test/generator-test.php diff --git a/package.json b/package.json index f1e13a5..3934390 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ ], "sideEffects": true, "scripts": { - "test": "node --test test/*.test.mjs" + "test": "node --test test/*.test.mjs", + "test:wp": "php wordpress/test/generator-test.php" }, "engines": { "node": ">=18" diff --git a/wordpress/README.md b/wordpress/README.md new file mode 100644 index 0000000..408d17a --- /dev/null +++ b/wordpress/README.md @@ -0,0 +1,62 @@ +# Human/Machine View for WordPress + +The plugin source. WordPress runs a large share of the web and its plugin directory is a +search surface with its own SEO, which makes this the one artifact in the whole project with +a plausible path to four figures of installs. + +## Why it serves llms.txt rather than writing one + +Every other generator writes the file to disk once. On WordPress that is wrong twice over: +plenty of hosts have a read-only or inconsistently writable web root, and a file written +today is stale the moment somebody publishes a page. + +This answers `/llms.txt` from a rewrite rule, generated from the posts table and cached in a +transient that is dropped whenever anything is published, edited or deleted. No file, no +permissions, nothing to regenerate. + +A real file at the web root still wins, because rewrite rules only run when WordPress does. +That is deliberate: a site already publishing its own llms.txt keeps it. + +## Layout + +``` +human-machine-view/ + human-machine-view.php plugin header, routing, enqueue, SRI filter + readme.txt the directory listing, and the discovery surface + includes/ + class-hmv-generator.php builds the file from posts and settings + class-hmv-settings.php Settings → Human/Machine View +test/ + generator-test.php runs the generator against a thin WordPress stub +``` + +## Testing + +```bash +npm run test:wp # the generator, against stubs +find wordpress -name '*.php' -exec php -l {} \; # syntax +``` + +CI lints at **PHP 7.4**, the plugin's declared floor, not at whatever the runner ships. +Syntax that is fine on 8.x and fatal on 7.4 is precisely the failure a user hits and we +would not. + +There is no full WordPress integration test. The stub covers the generator, which is where +the logic is; the routing, enqueue and settings code is thin enough to read, and is the part +to check by hand in a real install before any release. + +## Publishing to the plugin directory + +Not automated, and not attempted yet. WordPress.org is a manual submission followed by a +human review queue, then SVN rather than git. In order: + +1. Submit the plugin for review at . +2. Wait for the reviewer. Expect a round of feedback. +3. On approval you get an SVN repo. `trunk/` gets the plugin, `tags/1.0.0/` the release, + `assets/` the screenshots and banner. + +The `readme.txt` matters more than it looks: it is the listing copy **and** the text the +directory's own search indexes, so it is written for somebody typing "llms.txt" or "AI +search" into the plugin installer, not for somebody who already knows what this is. + +Screenshots referenced in `readme.txt` still need taking against a real install. diff --git a/wordpress/human-machine-view/human-machine-view.php b/wordpress/human-machine-view/human-machine-view.php new file mode 100644 index 0000000..6ae4d69 --- /dev/null +++ b/wordpress/human-machine-view/human-machine-view.php @@ -0,0 +1,239 @@ + + */ +function hmv_settings() { + $defaults = array( + 'serve_llms' => 1, + 'show_pill' => 1, + 'position' => 'bottom-center', + 'accent' => '#a8f326', + 'summary' => '', + 'about' => '', + 'facts' => '', + 'max_pages' => 40, + ); + $saved = get_option( HMV_OPTION, array() ); + return wp_parse_args( is_array( $saved ) ? $saved : array(), $defaults ); +} + +/* ------------------------------------------------------------------------- + * Serving /llms.txt + * ---------------------------------------------------------------------- */ + +add_action( 'init', 'hmv_add_rewrite' ); +/** Registers /llms.txt as a route WordPress answers. */ +function hmv_add_rewrite() { + add_rewrite_rule( '^llms\.txt$', 'index.php?hmv_llms=1', 'top' ); + add_rewrite_tag( '%hmv_llms%', '1' ); +} + +add_filter( 'query_vars', 'hmv_query_vars' ); +/** + * @param array $vars Registered query vars. + * @return array + */ +function hmv_query_vars( $vars ) { + $vars[] = 'hmv_llms'; + return $vars; +} + +add_action( 'template_redirect', 'hmv_maybe_serve_llms' ); +/** Answers /llms.txt as plain text, or gets out of the way. */ +function hmv_maybe_serve_llms() { + if ( ! get_query_var( 'hmv_llms' ) ) { + return; + } + $settings = hmv_settings(); + if ( empty( $settings['serve_llms'] ) ) { + return; // Turned off: fall through to a 404 rather than serving an empty file. + } + + $body = get_transient( HMV_CACHE_KEY ); + if ( false === $body ) { + $body = HMV_Generator::build( $settings ); + set_transient( HMV_CACHE_KEY, $body, DAY_IN_SECONDS ); + } + + status_header( 200 ); + header( 'Content-Type: text/plain; charset=utf-8' ); + header( 'X-Content-Type-Options: nosniff' ); + header( 'Cache-Control: public, max-age=300' ); + echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- plain text, escaped at build. + exit; +} + +/** + * Publishing anything invalidates the machine reading, because the Pages list is built + * from posts. Cheaper to drop the cache than to work out whether this particular post + * would have appeared in it. + */ +add_action( 'save_post', 'hmv_flush_cache' ); +add_action( 'deleted_post', 'hmv_flush_cache' ); +add_action( 'update_option_blogname', 'hmv_flush_cache' ); +add_action( 'update_option_blogdescription', 'hmv_flush_cache' ); +add_action( 'update_option_' . HMV_OPTION, 'hmv_flush_cache' ); +/** Drops the cached llms.txt. */ +function hmv_flush_cache() { + delete_transient( HMV_CACHE_KEY ); +} + +/* ------------------------------------------------------------------------- + * The front end + * ---------------------------------------------------------------------- */ + +add_action( 'wp_head', 'hmv_discovery_link', 1 ); +/** The tag the component looks for, so it never has to probe. */ +function hmv_discovery_link() { + $settings = hmv_settings(); + if ( empty( $settings['serve_llms'] ) ) { + return; + } + printf( + '' . "\n", + esc_url( home_url( '/llms.txt' ) ) + ); +} + +add_action( 'wp_enqueue_scripts', 'hmv_enqueue' ); +/** + * Loads the component and, critically, the :defined guard. + * + * The guard is not optional and is the single thing a host site gets wrong by hand: until + * the script loads, the browser treats as an ordinary in-flow box + * about 50px tall and pushes the page down. Enqueuing it here means a plugin user cannot + * skip it. + */ +function hmv_enqueue() { + $settings = hmv_settings(); + if ( empty( $settings['show_pill'] ) || empty( $settings['serve_llms'] ) ) { + return; + } + + wp_register_style( 'human-machine-view', false, array(), HMV_VERSION ); + wp_enqueue_style( 'human-machine-view' ); + wp_add_inline_style( + 'human-machine-view', + 'human-machine-swapper:not(:defined){display:none}' + ); + + wp_enqueue_script( + 'human-machine-swapper', + sprintf( + 'https://unpkg.com/human-machine-swapper@%s/src/human-machine-swapper.js', + HMV_COMPONENT_VERSION + ), + array(), + null, // Version is already in the URL; a ?ver= query would break the SRI match. + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); +} + +add_filter( 'script_loader_tag', 'hmv_script_integrity', 10, 3 ); +/** + * Adds integrity and crossorigin to our script tag only. + * + * WordPress has no API for SRI, so the tag has to be filtered. Scoped by handle so no + * other plugin's script is touched. + * + * @param string $tag The script tag. + * @param string $handle Script handle. + * @param string $src Script source. + * @return string + */ +function hmv_script_integrity( $tag, $handle, $src ) { + if ( 'human-machine-swapper' !== $handle ) { + return $tag; + } + return sprintf( + '' . "\n", + esc_url( $src ), + esc_attr( HMV_COMPONENT_SRI ) + ); +} + +add_action( 'wp_footer', 'hmv_render_element' ); +/** The element itself. position:fixed, so where it sits in the DOM does not matter. */ +function hmv_render_element() { + $settings = hmv_settings(); + if ( empty( $settings['show_pill'] ) || empty( $settings['serve_llms'] ) ) { + return; + } + printf( + '' . "\n", + esc_attr( $settings['position'] ), + esc_attr( $settings['accent'] ), + esc_url( home_url( '/llms.txt' ) ) + ); +} + +/* ------------------------------------------------------------------------- + * Activation + * ---------------------------------------------------------------------- */ + +register_activation_hook( __FILE__, 'hmv_activate' ); +/** Rewrite rules only take effect once flushed, and flushing is expensive, so: once. */ +function hmv_activate() { + hmv_add_rewrite(); + flush_rewrite_rules(); +} + +register_deactivation_hook( __FILE__, 'hmv_deactivate' ); +/** Leaves no dangling /llms.txt route behind. */ +function hmv_deactivate() { + delete_transient( HMV_CACHE_KEY ); + flush_rewrite_rules(); +} + +new HMV_Settings(); diff --git a/wordpress/human-machine-view/includes/class-hmv-generator.php b/wordpress/human-machine-view/includes/class-hmv-generator.php new file mode 100644 index 0000000..cbe7e4b --- /dev/null +++ b/wordpress/human-machine-view/includes/class-hmv-generator.php @@ -0,0 +1,216 @@ + $settings Plugin settings. + * @return string The whole file. + */ + public static function build( $settings ) { + $name = wp_strip_all_tags( get_bloginfo( 'name' ) ); + $tagline = wp_strip_all_tags( get_bloginfo( 'description' ) ); + $summary = trim( (string) ( $settings['summary'] ?? '' ) ); + $about = trim( (string) ( $settings['about'] ?? '' ) ); + $facts = trim( (string) ( $settings['facts'] ?? '' ) ); + + if ( '' === $summary ) { + $summary = $tagline; + } + + $out = array(); + $out[] = '# ' . ( '' !== $name ? $name : home_url() ); + $out[] = ''; + + if ( '' !== $summary ) { + $out[] = '> ' . self::one_line( $summary ); + $out[] = ''; + } + + $out[] = '## About'; + $out[] = ''; + if ( '' !== $about ) { + $out[] = self::clean( $about ); + } else { + // No invented prose. Say what is missing so the owner can fill it in, the same + // way the CLI leaves TODOs rather than guessing. + $out[] = 'TODO: a paragraph or two a language model can quote about ' + . ( '' !== $name ? $name : 'this site' ) . '.'; + } + $out[] = ''; + + $pages = self::pages( (int) ( $settings['max_pages'] ?? 40 ) ); + if ( ! empty( $pages ) ) { + $out[] = '## Pages'; + $out[] = ''; + foreach ( $pages as $page ) { + $out[] = sprintf( '- [%s](%s)', $page['title'], $page['url'] ); + } + $out[] = ''; + } + + $posts = self::recent_posts( 10 ); + if ( ! empty( $posts ) ) { + $out[] = '## Recent writing'; + $out[] = ''; + foreach ( $posts as $post ) { + $out[] = sprintf( '- [%s](%s)', $post['title'], $post['url'] ); + } + $out[] = ''; + } + + if ( '' !== $facts ) { + $out[] = '## Key facts'; + $out[] = ''; + $out[] = self::clean( $facts ); + $out[] = ''; + } + + $out[] = '## Links'; + $out[] = ''; + $out[] = '- Website: ' . home_url( '/' ); + $out[] = ''; + + $out[] = '---'; + $out[] = ''; + $out[] = self::clean( + 'Served by the Human/Machine View plugin for WordPress, generated from this ' + . 'site\'s own pages. https://advancelabs.dev/human-machine-swapper . The ' + . 'llms.txt convention: https://llmstxt.org' + ); + $out[] = ''; + + return implode( "\n", $out ); + } + + /** + * Published pages, in menu order, excluding the ones nobody wants a model quoting. + * + * @param int $limit Maximum pages to list. + * @return array + */ + private static function pages( $limit ) { + $limit = max( 1, min( 200, (int) $limit ) ); + + $query = new WP_Query( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 'orderby' => array( 'menu_order' => 'ASC', 'title' => 'ASC' ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + // A password-protected page is not public, so it does not belong in a file + // written for machines to read. + 'has_password' => false, + ) + ); + + $excluded = self::excluded_page_ids(); + $out = array(); + foreach ( $query->posts as $page ) { + if ( in_array( (int) $page->ID, $excluded, true ) ) { + continue; + } + $title = wp_strip_all_tags( get_the_title( $page ) ); + if ( '' === trim( $title ) ) { + continue; + } + $out[] = array( + 'title' => self::one_line( $title ), + 'url' => get_permalink( $page ), + ); + } + wp_reset_postdata(); + return $out; + } + + /** + * Pages that should never appear: the privacy policy is boilerplate, and anything the + * site owner has told search engines to leave alone should not be handed to a model + * either. + * + * @return array + */ + private static function excluded_page_ids() { + $ids = array(); + $privacy = (int) get_option( 'wp_page_for_privacy_policy' ); + if ( $privacy ) { + $ids[] = $privacy; + } + return $ids; + } + + /** + * @param int $limit Maximum posts. + * @return array + */ + private static function recent_posts( $limit ) { + $query = new WP_Query( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => max( 1, (int) $limit ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'has_password' => false, + ) + ); + $out = array(); + foreach ( $query->posts as $post ) { + $title = wp_strip_all_tags( get_the_title( $post ) ); + if ( '' === trim( $title ) ) { + continue; + } + $out[] = array( + 'title' => self::one_line( $title ), + 'url' => get_permalink( $post ), + ); + } + wp_reset_postdata(); + return $out; + } + + /** + * Collapses whitespace. Used where a newline would break the format, such as the + * one-line summary after the > marker or a link label. + * + * @param string $text Input. + * @return string + */ + private static function one_line( $text ) { + return trim( preg_replace( '/\s+/', ' ', (string) $text ) ); + } + + /** + * Normalises line endings and strips tags from owner-entered prose, keeping paragraph + * breaks. The settings screen accepts plain text, and this is what stops a pasted + * block of HTML ending up in a file that is meant to be plain. + * + * @param string $text Input. + * @return string + */ + private static function clean( $text ) { + $text = wp_strip_all_tags( (string) $text ); + $text = str_replace( array( "\r\n", "\r" ), "\n", $text ); + return trim( $text ); + } +} diff --git a/wordpress/human-machine-view/includes/class-hmv-settings.php b/wordpress/human-machine-view/includes/class-hmv-settings.php new file mode 100644 index 0000000..84b74b6 --- /dev/null +++ b/wordpress/human-machine-view/includes/class-hmv-settings.php @@ -0,0 +1,226 @@ +' . esc_html__( 'Settings', 'human-machine-view' ) . '' + ); + return $links; + } + + /** Registers the single option, sanitised as a whole. */ + public function register() { + register_setting( + self::GROUP, + HMV_OPTION, + array( + 'type' => 'array', + 'sanitize_callback' => array( $this, 'sanitize' ), + 'default' => array(), + ) + ); + } + + /** + * Everything that reaches the database passes through here. + * + * The prose fields are stripped of tags rather than escaped, because their destination + * is a plain-text file: HTML in llms.txt is noise to the thing meant to read it. + * + * @param mixed $input Raw submitted values. + * @return array + */ + public function sanitize( $input ) { + $input = is_array( $input ) ? $input : array(); + + $position = isset( $input['position'] ) ? (string) $input['position'] : 'bottom-center'; + if ( ! in_array( $position, array( 'bottom-center', 'top-center' ), true ) ) { + $position = 'bottom-center'; + } + + $accent = isset( $input['accent'] ) ? sanitize_hex_color( (string) $input['accent'] ) : ''; + if ( ! $accent ) { + $accent = '#a8f326'; + } + + $max_pages = isset( $input['max_pages'] ) ? (int) $input['max_pages'] : 40; + $max_pages = max( 1, min( 200, $max_pages ) ); + + return array( + 'serve_llms' => empty( $input['serve_llms'] ) ? 0 : 1, + 'show_pill' => empty( $input['show_pill'] ) ? 0 : 1, + 'position' => $position, + 'accent' => $accent, + 'summary' => sanitize_textarea_field( (string) ( $input['summary'] ?? '' ) ), + 'about' => sanitize_textarea_field( (string) ( $input['about'] ?? '' ) ), + 'facts' => sanitize_textarea_field( (string) ( $input['facts'] ?? '' ) ), + 'max_pages' => $max_pages, + ); + } + + /** Renders the page. */ + public function render() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $s = hmv_settings(); + $url = home_url( '/llms.txt' ); + ?> +
+

+ +

+ ' . esc_html( $url ) . '' + ); + ?> +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

+ + advancelabs.dev +

+
+ ` tag so tools can find it +* Lets you write the one-line summary, the About paragraph and your key facts +* Optionally adds a **Human / Machine** toggle so visitors can read the same file +* Excludes your privacy policy, password-protected content and untitled pages +* Regenerates automatically when you publish, edit or delete anything + += The Human/Machine toggle = + +Optional and off-switchable. It puts a small pill on the page; Machine shows your `llms.txt` rendered in place, so a visitor can see exactly what the machines see. It is a 6 KB web component with no dependencies, loaded from a version-pinned CDN URL with a Subresource Integrity hash, so the file it loads cannot change without the tag changing too. + += Already have an llms.txt? = + +Keep it. A real file at your web root is served before WordPress runs, so it always wins. You can also switch the generated one off and keep just the toggle pointing at your own file. + += Open source = + +Apache-2.0. The component and the generator are at [github.com/Advance-Labs/human-machine-swapper](https://github.com/Advance-Labs/human-machine-swapper). Built by [Advance Labs](https://advancelabs.dev). + +== Installation == + +1. Install and activate the plugin. +2. Visit **Settings → Human/Machine View**. +3. Write the one-line summary and the About paragraph. Two minutes. +4. Visit `yourdomain.com/llms.txt` to read what a model will read. + +If `/llms.txt` returns a 404 immediately after activating, go to **Settings → Permalinks** and click Save. That rebuilds the rewrite rules WordPress uses to answer the URL. + +== Frequently Asked Questions == + += What is an llms.txt file? = + +A plain-text file at the root of a website, written for language models rather than for people. It is a convention rather than a standard, and no engine is obliged to read it. Publishing one is cheap; the cost of not having one is that models answer from whatever they happened to scrape. + += Will this get my site into ChatGPT? = + +No, and be suspicious of anything that says otherwise. An llms.txt makes what you publish easy to read and quote correctly. It does not make an engine cite you, and no plugin can promise that. + += Does it slow my site down? = + +No. The `/llms.txt` output is cached and only rebuilt when you publish or edit something. The optional pill is a 6 KB script loaded with `defer`, after your page has rendered. + += Does it change how my site looks? = + +Only if you turn the pill on, and that is one checkbox. Nothing else about your theme is touched. + += Can I control what goes in the file? = + +Yes. The summary, the About paragraph and the key facts are yours to write. Pages come from your published pages in menu order, and you can cap how many are listed. + += What about private or protected content? = + +Password-protected posts and pages are excluded, as is your privacy policy page, as is anything unpublished. The file only ever contains what is already public on your site. + += Is it free? = + +Yes, and open source under Apache-2.0. No account, no upsell, no data sent anywhere. + +== Screenshots == + +1. Settings → Human/Machine View: the summary, About and key facts that shape your llms.txt. +2. The generated llms.txt, built from the site's own pages. +3. The optional Human / Machine pill, showing the machine reading in place. + +== Changelog == + += 1.0.0 = +* First release. Serves llms.txt from your published pages, adds the discovery link, and optionally shows the Human/Machine pill. diff --git a/wordpress/test/generator-test.php b/wordpress/test/generator-test.php new file mode 100644 index 0000000..9925139 --- /dev/null +++ b/wordpress/test/generator-test.php @@ -0,0 +1,136 @@ +post_title; +} +function get_permalink( $post ) { + return home_url( '/' . $post->slug ); +} +function wp_reset_postdata() {} + +/** Stands in for WP_Query, returning whichever fixture list the args ask for. */ +class WP_Query { + public $posts = array(); + public function __construct( $args ) { + $key = 'page' === $args['post_type'] ? 'hmv_test_pages' : 'hmv_test_posts'; + $this->posts = array_slice( $GLOBALS[ $key ], 0, (int) $args['posts_per_page'] ); + } +} + +/** Minimal post object. */ +class HMV_Test_Post { + public $ID; + public $post_title; + public $slug; + public function __construct( $id, $title, $slug ) { + $this->ID = $id; + $this->post_title = $title; + $this->slug = $slug; + } +} + +require_once __DIR__ . '/../human-machine-view/includes/class-hmv-generator.php'; + +// ── assertions ───────────────────────────────────────────────────────────────────────── + +$failures = 0; +function check( $label, $condition, $detail = '' ) { + global $failures; + if ( $condition ) { + echo " ok $label\n"; + return; + } + $failures++; + echo " FAIL $label" . ( $detail ? " — $detail" : '' ) . "\n"; +} + +$GLOBALS['hmv_test_pages'] = array( + new HMV_Test_Post( 1, 'Repairs and servicing', 'repairs' ), + new HMV_Test_Post( 7, 'Privacy Policy', 'privacy-policy' ), + new HMV_Test_Post( 3, '', 'untitled' ), + new HMV_Test_Post( 4, 'Join the co-op', 'membership' ), +); +$GLOBALS['hmv_test_posts'] = array( + new HMV_Test_Post( 20, 'Winter tyre clinic', 'winter-tyre-clinic' ), +); + +echo "generator, with everything filled in\n"; +$out = HMV_Generator::build( + array( + 'summary' => 'A bike shop and repair co-op on the Halifax waterfront.', + 'about' => "We have repaired bikes since 2011.\nMembers get workshop access.", + 'facts' => "Q: What are your hours?\nA: Tuesday to Saturday, 10 to 6.", + 'max_pages' => 40, + ) +); + +check( 'starts with the site name as an h1', str_starts_with( $out, '# Harbour Cycles' ) ); +check( 'carries the one-line summary', str_contains( $out, '> A bike shop and repair co-op on the Halifax waterfront.' ) ); +check( 'lists a published page', str_contains( $out, '- [Repairs and servicing](https://harbourcycles.test/repairs)' ) ); +check( 'lists recent writing', str_contains( $out, '- [Winter tyre clinic](' ) ); +check( 'carries the owner key facts', str_contains( $out, 'Q: What are your hours?' ) ); + +// The three exclusions that stop the file stating things the site does not. +check( 'excludes the privacy policy', ! str_contains( $out, 'Privacy Policy' ), 'boilerplate, not a machine reading' ); +check( 'skips an untitled page', ! str_contains( $out, '](https://harbourcycles.test/untitled)' ) ); +check( 'collapses whitespace in titles', str_contains( $out, '- [Join the co-op]' ) ); + +echo "\ngenerator, with nothing filled in\n"; +$bare = HMV_Generator::build( array( 'max_pages' => 40 ) ); +check( 'falls back to the tagline for the summary', str_contains( $bare, '> A bike shop and repair co-op in Halifax.' ) ); +check( 'admits the gap rather than inventing an About', str_contains( $bare, 'TODO' ), 'an invented About is worse than none' ); +check( 'omits Key facts entirely when empty', ! str_contains( $bare, '## Key facts' ) ); + +echo "\ngenerator, with HTML pasted into the prose fields\n"; +$dirty = HMV_Generator::build( + array( + 'about' => '

We fix bikes.

', + 'max_pages' => 40, + ) +); +check( 'strips tags from owner prose', ! str_contains( $dirty, '