<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Frontend rendering.
 *  - On the post types the builder handles: replaces the_content with the rendered template.
 *  - Shortcode [ai_section id="123"] to embed a section anywhere.
 */
class AIPB_Render {

	public static function init() {
		// HIGH priority (after wpautop/wptexturize/shortcode): our output contains <style> and
		// <script>, which wpautop would break by inserting <p>/<br> inside them. Running last, we
		// hand back the finished HTML and no filter touches it.
		add_filter( 'the_content', array( __CLASS__, 'render_content' ), 99 );
		add_shortcode( 'ai_section', array( __CLASS__, 'shortcode' ) );
		add_action( 'wp_head', array( __CLASS__, 'frontend_reset' ) );
		// "Canvas" pages: full-page template that bypasses the theme's header/footer.
		add_filter( 'template_include', array( __CLASS__, 'canvas_template' ), 99 );

		// FSE templates built with Aura: render them LIVE, not from the saved snapshot.
		add_filter( 'template_include', array( __CLASS__, 'live_block_template' ), 100 );
		add_filter( 'get_block_template', array( __CLASS__, 'live_template_part' ), 10, 3 );
		add_action( 'init', array( __CLASS__, 'register_section_block' ) );

		// Our content lives in the meta: make it travel with WP revisions.
		add_action( '_wp_put_post_revision', array( __CLASS__, 'save_revision_meta' ) );
		add_action( 'wp_restore_post_revision', array( __CLASS__, 'restore_revision_meta' ), 10, 2 );
		// The changes are in the meta (not in post_content): force the revision to be created.
		add_filter( 'wp_save_post_revision_check_for_changes', array( __CLASS__, 'force_builder_revision' ), 10, 3 );
	}

	public static function force_builder_revision( $check, $last_revision, $post ) {
		if ( $post && AIPB_Config::is_builder_post_type( $post->post_type ) ) {
			return false; // false = do not compare, save the revision anyway
		}
		return $check;
	}

	/* ------------------------------- Revisions ------------------------------- */

	protected static function revision_meta_keys() {
		return array( AIPB_META_TEMPLATE, AIPB_META_SCHEMA, AIPB_META_VALUES, AIPB_META_BLOCKS );
	}

	/** Copies the post's meta into the revision that was just created. */
	public static function save_revision_meta( $revision_id ) {
		$revision = get_post( $revision_id );
		if ( ! $revision ) {
			return;
		}
		foreach ( self::revision_meta_keys() as $key ) {
			$val = get_post_meta( $revision->post_parent, $key, true );
			if ( '' !== $val && false !== $val ) {
				update_metadata( 'post', $revision_id, $key, wp_slash( $val ) );
			}
		}
	}

	/** Restores the meta from the revision when the user rolls one back. */
	public static function restore_revision_meta( $post_id, $revision_id ) {
		foreach ( self::revision_meta_keys() as $key ) {
			$val = get_metadata( 'post', $revision_id, $key, true );
			if ( '' !== $val && false !== $val ) {
				update_post_meta( $post_id, $key, wp_slash( $val ) );
			} else {
				delete_post_meta( $post_id, $key );
			}
		}
	}

	/* -------------------------------- <head> --------------------------------- */

	/**
	 * In the <head> of pages built with the builder:
	 *  1. zero out the <body>'s default margin (the browser's 8px), so full-bleed sections reach
	 *     the edges;
	 *  2. a shim for DOMContentLoaded: sections include <script> that sometimes use
	 *     document.addEventListener('DOMContentLoaded', …). If the event has ALREADY fired when the
	 *     script runs (it depends on the theme), that listener would never start. The shim runs it
	 *     straight away. It has to be in the head, BEFORE the sections' scripts.
	 */
	public static function frontend_reset() {
		// Either the content belongs to Aura, or the TEMPLATE drawing it does: either way Aura
		// sections end up on the page, and without these variables every var(--aipb-*) they contain
		// stays undefined — that is, the page comes out with no colours, fonts or spacing.
		$id    = is_singular() ? (int) get_queried_object_id() : 0;
		$owned = $id
			&& AIPB_Config::is_builder_post_type( get_post_type( $id ) )
			&& AIPB_Ownership::is_aura( $id )
			&& AIPB_Ownership::has_aura_content( $id );

		if ( ! $owned && ! self::aura_template_id() ) {
			return;
		}
		// With the theme's styles removed the basic normalisations are missing too: the reset gets a
		// touch broader (see AIPB_Assets::reset_css).
		$reset = AIPB_Assets::isolating() ? AIPB_Assets::reset_css() : 'body{margin:0}';
		echo '<style id="aipb-reset">' . $reset . ".aipb-s{display:block}.aipb-page-layer{position:fixed;inset:0;pointer-events:none;z-index:40}.aipb-page-layer [data-aipb-hit]{pointer-events:auto}</style>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — constant CSS, no input

		self::print_fonts();

		echo '<style id="aipb-tokens">' . AIPB_Design::css_variables() . "</style>\n";
		echo '<style id="aipb-motion">' . self::motion_css() . "</style>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — constant CSS, no input
		// The brand's effects. Sewing the fallbacks happens here, at print time, so the values are
		// the tokens of this moment — same reasoning as the sections' own CSS (see add_fallbacks).
		$fx = AIPB_Effects::compiled_css();
		if ( '' !== $fx ) {
			echo '<style id="aipb-fx">' . AIPB_Design::add_fallbacks( $fx ) . "</style>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — validated CSS (see AIPB_Effects::validate_css)
		}
		?>
<script id="aipb-dcl-shim">
(function(){
	if(window.__aipbDCL){return;} window.__aipbDCL=1;
	var add=document.addEventListener.bind(document);
	document.addEventListener=function(type,listener,opts){
		if(type==='DOMContentLoaded'&&document.readyState!=='loading'&&typeof listener==='function'){
			setTimeout(function(){ try{ listener.call(document,new Event('DOMContentLoaded')); }catch(e){} },0);
			return;
		}
		return add(type,listener,opts);
	};
})();
</script>
		<?php
		// The sections runtime: runs the <script>s while confining them to their own section.
		echo '<script id="aipb-runtime">' . self::runtime_js() . "</script>\n";
	}

	/**
	 * Self-hosted fonts (performance + GDPR: no request to Google from the visitor's browser).
	 * When they are not available (self-hosting off, or generation failed) it falls back to the
	 * <link> to Google.
	 */
	protected static function print_fonts() {
		$fonts_css = AIPB_Fonts::frontend_style();
		if ( '' !== $fonts_css ) {
			echo $fonts_css . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — @font-face CSS from Google (trusted source), URLs rewritten onto local files (uploads + md5 name)
			return;
		}

		$fonts_url = AIPB_Design::google_fonts_url();
		if ( ! $fonts_url ) {
			return;
		}
		echo '<link rel="preconnect" href="https://fonts.googleapis.com">' . "\n";
		echo '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' . "\n";
		// Preloading the font sheet brings its fetch forward (it helps LCP), then it applies as a
		// stylesheet.
		echo '<link rel="preload" as="style" href="' . esc_url( $fonts_url ) . '">' . "\n";
		echo '<link id="aipb-fonts" rel="stylesheet" href="' . esc_url( $fonts_url ) . '">' . "\n";
	}

	/* --------------------------------- Canvas -------------------------------- */

	/**
	 * A page built with Aura goes to canvas: ALWAYS, unless told otherwise explicitly.
	 *
	 * The reason is that generated sections already contain their own header and footer (often
	 * saved as patterns from the theme and reused in here). Leaving the theme's chrome as well
	 * means two headers on the same page — and, since AIPB_Assets removes the theme's CSS on these
	 * pages, the duplicate stays there unstyled too. It was the most common cause of "it is perfect
	 * in the preview, it is broken once published": the preview shows the sections alone, the page
	 * does not.
	 *
	 * It is NOT a per-page preference, and that is a lesson learned the hard way: when it was, a
	 * single Studio tab left open from before an update was enough to send "canvas: false" back on
	 * the next save and bring the theme's header and footer back onto a page that worked. A switch
	 * nobody touched must not be able to break a page: now the rule lives here only, and no client
	 * can contradict it.
	 *
	 * The exception — a landing page built here but to be fitted INSIDE an existing site — is still
	 * possible in code, through the `aipb_canvas` filter. Whoever uses it knows what they are doing,
	 * and should remember to turn isolation off as well (see AIPB_Assets).
	 */
	public static function use_canvas( $id ) {
		$id = (int) $id;
		if ( ! $id || ! AIPB_Config::is_page_like( get_post_type( $id ) ) ) {
			return false;
		}
		// No built content, or a page handed back to Gutenberg: the theme is in charge.
		if ( ! AIPB_Ownership::is_aura( $id ) || ! AIPB_Ownership::has_aura_content( $id ) ) {
			return false;
		}
		return (bool) apply_filters( 'aipb_canvas', true, $id );
	}

	/** Serves the full-page template when use_canvas() asks for it. */
	public static function canvas_template( $template ) {
		if ( ! is_singular() || ! self::use_canvas( get_queried_object_id() ) ) {
			return $template;
		}
		$canvas = AIPB_PATH . 'templates/canvas.php';
		return file_exists( $canvas ) ? $canvas : $template;
	}

	/* -------------------------------- Entry ---------------------------------- */

	public static function render_content( $content ) {
		if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
			return $content;
		}
		$post = get_post();
		if ( ! $post || ! AIPB_Config::is_builder_post_type( $post->post_type ) ) {
			return $content;
		}
		// Content handed back to Gutenberg: Aura's meta stay (so it can be handed back again) but
		// must no longer govern the output — post_content is in charge.
		if ( ! AIPB_Ownership::is_aura( $post->ID ) ) {
			return $content;
		}
		$html = self::render_post( $post->ID );
		return $html ? self::finalize_html( $html ) : $content;
	}

	public static function shortcode( $atts ) {
		$atts = shortcode_atts( array( 'id' => 0 ), $atts, 'ai_section' );
		// The ID was written into the template when the page was built, in one language. On a
		// multilingual site the section it names has a counterpart in every other one, and it is
		// that counterpart the page has to embed — see AIPB_WPML::object_id, which hands back the
		// same ID unchanged wherever there is only one language.
		$id   = AIPB_WPML::object_id( (int) $atts['id'] );
		$html = $id ? self::render_post( $id ) : '';
		return $html ? self::finalize_html( $html ) : '';
	}

	/**
	 * Posts currently being rendered: the guard against recursion.
	 *
	 * Two sources of recursion, both covered because the guard is keyed by post ID:
	 *  1. A source="post" loop calls get_the_excerpt(), which reapplies the_content on the same
	 *     main post (an inner WP_Query is not the global $wp_query).
	 *  2. A page with a "ref" block pointing at a component which, in turn (via shortcode/loop),
	 *     points back at the page itself → a cycle.
	 */
	protected static $rendering = array();

	protected static function render_post( $id ) {
		$id = (int) $id;
		if ( isset( self::$rendering[ $id ] ) ) {
			return '';
		}

		$post = get_post( $id );
		if ( ! $post ) {
			return '';
		}

		self::$rendering[ $id ] = true;

		// WHERE THE CONTENT COMES FROM is not always this post: a translation created without our
		// meta draws from the original, and the words are put into the right language on the way
		// out. With no translation plugin installed this is $id and nothing changes.
		$src = AIPB_WPML::content_id( $id );

		// Pages/posts: a stack of blocks (falling back to the single legacy template).
		$blocks = ( AIPB_CPT::POST_TYPE !== $post->post_type ) ? self::get_blocks( $src ) : array();

		// Component (ai_section) or legacy page: a single template.
		$html = $blocks ? self::render_blocks( $blocks, true, $src ) : self::render_single( $src, true );

		unset( self::$rendering[ $id ] );
		return $html;
	}

	/**
	 * Renders a stack of blocks. Shared between the frontend and the REST preview.
	 *  - inline: the block's own template+values (the engine sanitises the values).
	 *  - ref:    points at a published ai_section component (a live link).
	 *
	 * @param array $blocks
	 * @param bool  $expand Whether to expand shortcodes; false for the static snapshot, where they
	 *                      have to stay literal.
	 * @param int   $post_id Which content the blocks came from. It is what the texts are looked up
	 *                       under when a translation plugin is in play; 0 where there is no post to
	 *                       speak of (the Studio's preview of a single block).
	 */
	public static function render_blocks( $blocks, $expand = true, $post_id = 0 ) {
		if ( ! is_array( $blocks ) ) {
			return '';
		}

		// ONLY ON THE WAY TO A VISITOR. $expand tells the live page from the static snapshot, and
		// the snapshot is written back into post_content on save: translating it there would freeze
		// whatever language the admin happened to be browsing in into the source content.
		if ( $expand && $post_id ) {
			$blocks = AIPB_WPML::translate( $blocks, (int) $post_id );
		}

		$flow   = '';
		$layers = '';
		foreach ( $blocks as $b ) {
			if ( ! is_array( $b ) ) {
				continue;
			}
			$type = isset( $b['type'] ) ? $b['type'] : 'inline';

			if ( 'ref' === $type ) {
				// Same as the shortcode: the reference names a component in the language the page
				// was built in, and the page being drawn may be in another one.
				$ref = AIPB_WPML::object_id( isset( $b['ref'] ) ? (int) $b['ref'] : 0 );
				if ( $ref && 'publish' === get_post_status( $ref ) ) {
					$flow .= $expand ? self::render_post( $ref ) : self::compile_html( $ref );
				}
				continue;
			}

			if ( ! empty( $b['template'] ) ) {
				$values = isset( $b['values'] ) && is_array( $b['values'] ) ? $b['values'] : array();
				$bid    = isset( $b['id'] ) ? (string) $b['id'] : '';
				$anchor = isset( $b['anchor'] ) ? (string) $b['anchor'] : '';
				$name   = isset( $b['name'] ) ? (string) $b['name'] : '';
				$page   = self::is_page_layer( $b );
				$html   = self::light_wrap(
					AIPB_Template_Engine::render( (string) $b['template'], $values ),
					$bid,
					$expand,
					$page ? '' : $anchor,
					$page,
					$name
				);
				if ( $page ) {
					$layers .= $html;
				} else {
					$flow .= $html;
				}
			}
		}
		return $flow . $layers;
	}

	/** The single template of a component or of a legacy page. */
	protected static function render_single( $id, $expand ) {
		$template = get_post_meta( $id, AIPB_META_TEMPLATE, true );
		if ( ! $template ) {
			return '';
		}
		$values = json_decode( get_post_meta( $id, AIPB_META_VALUES, true ), true );
		$values = is_array( $values ) ? $values : array();

		// See render_blocks(): the live page is translated, the snapshot is not.
		if ( $expand && AIPB_WPML::active() ) {
			$translated = AIPB_WPML::translate( AIPB_Strings::blocks( $id ), (int) $id );
			if ( isset( $translated[0]['values'] ) && is_array( $translated[0]['values'] ) ) {
				$values = $translated[0]['values'];
			}
		}

		return self::light_wrap(
			AIPB_Template_Engine::render( $template, $values ),
			'',
			$expand
		);
	}

	/* ---------------------- Section isolation (light DOM) -------------------- */

	/**
	 * Takes out of a section everything that would call a third-party server.
	 *
	 * WHAT IT SOLVES. The model writes raw `<style>` and `<script>`, and as long as nothing stopped
	 * it, it would sometimes pull in a library from a CDN — at random, because the prompt had no
	 * rule either way. The customer's site pays for it: it loads code from a domain it did not
	 * choose (and that domain sees every visitor's IP address), it breaks the day that server
	 * changes version or stops answering, and it hangs on a version number the model wrote from
	 * memory.
	 *
	 * It is the same decision already taken for the fonts: `sync_post_content` serves Google Fonts
	 * from local files precisely so the page does not call out. Nothing new is being decided here,
	 * that choice is simply being applied to the JS as well.
	 *
	 * IT IS NOT A SECURITY FILTER, and must not be mistaken for one: whoever saves has
	 * `unfiltered_html` and can hand-write whatever they like. This takes away what the MODEL wrote,
	 * which is the only thing adding a dependency nobody asked for.
	 *
	 * AND IT IS NOT SILENT: `AIPB_Check::check_external()` reports it on that section. A library
	 * that disappears quietly would give "the slider does not move" with nothing to read to find out
	 * why — a worse defect than the one being removed.
	 */
	protected static function strip_external( $html ) {
		// JS libraries: <script src="//cdn…">. The whole tag goes, body included — a tag with src
		// does not run what is inside it anyway, and leaving it there would only suggest something
		// survived.
		$html = preg_replace_callback( '#<script\b([^>]*)>(.*?)</script>#is', function ( $m ) {
			if ( '' === self::attr( $m[0], 'src' ) ) {
				return $m[0]; // inline: step 2 of light_wrap neutralises it, not our business here
			}

			return self::is_external_resource( self::attr( $m[0], 'src' ) ) ? '' : $m[0];
		}, $html );

		// Third-party stylesheets: <link rel="stylesheet" href="//cdn…">.
		$html = preg_replace_callback( '#<link\b[^>]*>#i', function ( $m ) {
			if ( ! preg_match( '/\brel\s*=\s*["\']?[^"\'>]*\bstylesheet\b/i', $m[0] ) ) {
				return $m[0];
			}

			return self::is_external_resource( self::attr( $m[0], 'href' ) ) ? '' : $m[0];
		}, $html );

		// @import: the other back door to the same CDN. `[^;{]` keeps the bite to the single
		// statement, so an @import with no trailing semicolon does not swallow the first rule it
		// finds.
		$html = preg_replace_callback( '#@import\b[^;{]*;#i', function ( $m ) {
			if ( ! preg_match( '#url\(\s*["\']?([^"\')]+)|["\']([^"\']+)["\']#i', $m[0], $u ) ) {
				return $m[0];
			}
			$url = '' !== (string) ( $u[1] ?? '' ) ? $u[1] : (string) ( $u[2] ?? '' );

			return self::is_external_resource( $url ) ? '' : $m[0];
		}, $html );

		return $html;
	}

	/** The value of an attribute in a tag, quoted or not. */
	protected static function attr( $tag, $name ) {
		$re = '/\b' . preg_quote( $name, '/' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i';
		if ( ! preg_match( $re, $tag, $m ) ) {
			return '';
		}
		foreach ( array( 2, 3, 4 ) as $i ) {
			if ( isset( $m[ $i ] ) && '' !== $m[ $i ] ) {
				return $m[ $i ];
			}
		}

		return '';
	}

	/**
	 * Does the URL point outside the site?
	 *
	 * A relative path is ours and stays. Anything with a host different from ours goes, including
	 * the protocol-relative `//cdn.example.com/…` which is how CDNs are written most often and the
	 * one thing a check on "https://" would miss.
	 *
	 * Public because `AIPB_Check` has to be able to give the SAME answer as the engine: a check
	 * reporting a URL the engine then lets through (or the other way round) would be worse than no
	 * check — it would send someone looking for a problem that is not there, or stay quiet about one
	 * that is.
	 */
	public static function is_external_resource( $url ) {
		$url = trim( html_entity_decode( (string) $url, ENT_QUOTES ) );
		if ( '' === $url || 0 === stripos( $url, 'data:' ) ) {
			return false;
		}
		if ( 0 === strpos( $url, '//' ) ) {
			$url = 'https:' . $url;
		}
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host ) {
			return false; // relative
		}

		return strtolower( $host ) !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
	}

	/**
	 * LIGHT DOM wrap: puts the section inside <div class="aipb-s" id="aipb-XXX">, scopes the CSS of
	 * its <style> tags under that id and makes the inline <script>s inert (the runtime runs them,
	 * confined to the section — see runtime_js). That way the content stays in the document
	 * (indexable, SEO-friendly) but styles and scripts do not "leak" out of the section.
	 *
	 * A page layer ($page = true) uses the same treatment under .aipb-page-layer: CSS still scoped
	 * to its own id, scripts still inert, but the wrapper sits outside the flow and the runtime
	 * hands its JS a document proxied onto the canvas rather than onto the wrapper.
	 *
	 * At the end it expands the shortcodes (see AIPB_Shortcodes): that is the point where the
	 * section is finished and our treatment has already passed, so what the plugin produces is left
	 * alone. $expand = false for the static SNAPSHOT, where shortcodes have to stay literal.
	 */
	public static function light_wrap( $html, $id = '', $expand = true, $anchor = '', $page = false, $name = '' ) {
		if ( '' === trim( (string) $html ) ) {
			return $html;
		}
		$slug  = '' !== (string) $id ? preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $id ) : '';
		$domid = 'aipb-' . ( '' !== $slug ? $slug : wp_unique_id() );
		$scope = '#' . $domid;

		// 0) Third-party resources out. Before everything else, because @imports have to go while
		//    the CSS is still the one the model wrote: after scoping they would already have got
		//    through untouched, since the scoper deliberately leaves them alone.
		$html = self::strip_external( $html );

		// 1) Scope the CSS of every <style> under the section's scope.
		$html = preg_replace_callback( '#<style\b[^>]*>(.*?)</style>#is', function ( $m ) use ( $scope ) {
			return '<style>' . AIPB_CSS_Scope::scope_css( $m[1], $scope ) . '</style>';
		}, $html );

		// 2) Make inline <script>s inert: the runtime runs them, confined to the section. The
		//    THIRD-PARTY src ones are no longer here (step 0 took them away); what is left is a src
		//    internal to the site, which is left as it is: turning it into text/plain would mean the
		//    runtime runs its body — empty — believing it has loaded the script.
		$html = preg_replace_callback( '#<script\b([^>]*)>(.*?)</script>#is', function ( $m ) {
			if ( preg_match( '/\bsrc\s*=/i', $m[1] ) ) {
				return '';
			}
			return '<script type="text/plain" data-aipb>' . $m[2] . '</script>';
		}, $html );

		// 3) Shortcodes: expanded NOW, last. The markup, CSS and JS coming from the plugin that
		//    registers them therefore go through neither the scoping nor the neutralisation.
		if ( $expand ) {
			$html = AIPB_Shortcodes::expand( $html );
		}

		$attr = '' !== (string) $id ? ' data-aipb-block="' . esc_attr( $id ) . '"' : '';
		if ( $page ) {
			$attr .= ' data-aipb-scope="page"';
		}
		if ( '' !== (string) $name ) {
			$attr .= ' data-aipb-name="' . esc_attr( $name ) . '"';
		}

		// The ANCHOR as an element of its own and not as the container's id: that id is the scope
		// under which the whole section's CSS has been confined, and changing it would unstick the
		// styles. An empty target costs nothing and makes in-page links work: without it, an
		// <a href="#prices"> written by the model leads nowhere — and nothing reports it, the
		// browser simply does not move. Page layers are not in the flow: they are not a link target.
		$anchor_tag = '';
		if ( ! $page && '' !== (string) $anchor ) {
			$anchor_tag = '<a id="' . esc_attr( $anchor ) . '" class="aipb-anchor" aria-hidden="true" tabindex="-1"></a>';
		}

		$class = $page ? 'aipb-page-layer' : 'aipb-s';

		return '<div class="' . $class . '" id="' . esc_attr( $domid ) . '"' . $attr . '>' . $anchor_tag . $html . '</div>';
	}

	/** Is this block a page-wide overlay rather than a section in the flow? */
	public static function is_page_layer( $block ) {
		return is_array( $block ) && isset( $block['scope'] ) && 'page' === $block['scope'];
	}

	/**
	 * The blocks that belong in the SEO snapshot: everything except page layers. A custom cursor
	 * is decoration, and Yoast fishing it out of post_content is how a bakery's description becomes
	 * the markup of a pointer.
	 *
	 * @param array $blocks
	 * @return array
	 */
	public static function content_blocks( $blocks ) {
		$out = array();
		foreach ( (array) $blocks as $b ) {
			if ( self::is_page_layer( $b ) ) {
				continue;
			}
			$out[] = $b;
		}

		return $out;
	}

	/**
	 * Runtime (once per page): for every <div class="aipb-s"> it runs its
	 * <script type="text/plain" data-aipb> with a document PROXIED onto the section's wrapper, so
	 * document.querySelector* stay confined to the section while the global APIs (scroll, etc.)
	 * keep working. Page layers (data-aipb-scope="page") get the same treatment with the proxy
	 * rooted on the canvas: they can see every section, the sections still cannot see each other.
	 */
	public static function runtime_js() {
		$motion = self::motion_js();
		$swiper = self::swiper_js();

		$sections = <<<'JS'
(function(){
	if(window.__aipbLite){return;} window.__aipbLite=1;
	function proxyDoc(root){
		return new Proxy(document,{get:function(t,p){
			if(p==='querySelector')return function(s){return root.querySelector(s);};
			if(p==='querySelectorAll')return function(s){return root.querySelectorAll(s);};
			if(p==='getElementById')return function(i){return root.querySelector('#'+(window.CSS&&CSS.escape?CSS.escape(i):i));};
			if(p==='getElementsByClassName')return function(c){return root.querySelectorAll('.'+c);};
			if(p==='addEventListener')return function(ty,fn,o){ if(ty==='DOMContentLoaded'){ setTimeout(function(){try{fn.call(root,new Event('DOMContentLoaded'));}catch(e){}},0); return; } return t.addEventListener(ty,fn,o); };
			var v=t[p]; return (typeof v==='function')?v.bind(t):v;
		}});
	}
	function pageRoot(){ return document.querySelector('.aipb-canvas')||document.body; }
	function bus(){
		if(window.aura){return;}
		var h={};
		window.aura={
			on:function(e,f){ (h[e]=h[e]||[]).push(f); },
			off:function(e,f){ if(!h[e])return; h[e]=h[e].filter(function(x){return x!==f;}); },
			emit:function(e,d){ var a=h[e]||[]; for(var i=0;i<a.length;i++){ try{a[i](d);}catch(x){} } }
		};
	}
	function process(host){
		if(host.__aipb){return;} host.__aipb=1;
		var page=host.getAttribute('data-aipb-scope')==='page';
		var scripts=host.querySelectorAll('script[type="text/plain"][data-aipb]'), pd=proxyDoc(page?pageRoot():host);
		function exec(){
			for(var i=0;i<scripts.length;i++){ var code=scripts[i].textContent; try{ (new Function('document','root',code))(pd,host); }catch(e){} }
		}
		// A section carrying a carousel gets its library FIRST, then its scripts: the code the model
		// wrote says `new Swiper(…)` on the first line, and a library arriving a moment later would
		// be a library that never arrived. Sections without a .swiper never wait and never load it.
		if(window.__aipbSwiper&&host.querySelector('.swiper')){ window.__aipbSwiper(exec); } else { exec(); }
	}
	function pageFx(){
		if(window.__aipbPageFx){return;}
		if(!document.querySelector('.aipb-page-layer')){return;}
		window.__aipbPageFx=1;
		bus();
		var root=document.documentElement, ticking=false;
		function scroll(){
			var max=Math.max((document.documentElement.scrollHeight||0)-innerHeight,1);
			var y=window.scrollY||document.documentElement.scrollTop||0;
			var p=Math.max(0,Math.min(1,y/max));
			root.style.setProperty('--aura-scroll-progress',String(p));
			window.aura.emit('scroll',{progress:p,y:y});
			ticking=false;
		}
		function onScroll(){ if(!ticking){ ticking=true; requestAnimationFrame(scroll); } }
		addEventListener('scroll',onScroll,{passive:true});
		addEventListener('resize',onScroll,{passive:true});
		scroll();
		if(!('IntersectionObserver' in window)){return;}
		var secs=document.querySelectorAll('.aipb-s');
		var io=new IntersectionObserver(function(entries){
			for(var i=0;i<entries.length;i++){
				var el=entries[i].target, idx=-1;
				for(var j=0;j<secs.length;j++){ if(secs[j]===el){ idx=j; break; } }
				window.aura.emit(entries[i].isIntersecting?'section:enter':'section:leave',{
					id:el.id, name:el.getAttribute('data-aipb-name')||'', index:idx, el:el
				});
			}
		},{threshold:0.25});
		for(var i=0;i<secs.length;i++){ io.observe(secs[i]); }
	}
	function run(){
		if(document.querySelector('.aipb-page-layer')){ bus(); }
		var hs=document.querySelectorAll('.aipb-s,.aipb-page-layer'); for(var i=0;i<hs.length;i++){process(hs[i]);}
		// The reveals are re-scanned after the scripts: a section that builds its own markup (a
		// slider cloning its slides) has to have its new elements observed too.
		if(window.__aipbReveal){window.__aipbReveal();}
		pageFx();
	}
	// Exposed for the Studio preview, which replaces a section without reloading the page:
	// sections already started have their own __aipb and are skipped, the new one starts.
	window.__aipbRun=run;
	if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',run);}else{run();}
})();
JS;

		return $motion . "\n" . $swiper . "\n" . $sections;
	}

	/* --------------------------------- Swiper -------------------------------- */

	/**
	 * The carousel library, served from here and loaded only by the pages that use one.
	 *
	 * WHY A LIBRARY AT ALL, after a whole chapter saying not to use any. The rule was never
	 * "libraries are bad", it was "the visitor's browser must not go and fetch code from somebody
	 * else's server": that is the sentence in strip_external(), and it is about the DOMAIN, not
	 * about the dependency. Swiper lives in assets/vendor, we pin its version, and nothing leaves
	 * the site — the same arrangement already made for Google Fonts and for the Lucide icons.
	 *
	 * WHY THIS ONE AND NOT OTHERS. Not because a carousel is hard: scroll-snap does the scrolling,
	 * and for a plain scrollable row it stays the right answer. It is that the model WRITES SWIPER
	 * CORRECTLY — it has seen it ten thousand times — while a hand-rolled carousel comes out
	 * different every time, and it is always the same three things that break: the dots that stop
	 * following the position, the autoplay that does not pause on focus, the drag that swallows the
	 * click on the link inside the slide. It is bought with 154KB on the pages that have a carousel
	 * and zero on the others, which is the shape of the deal that makes it worth it.
	 *
	 * LAZY, AND FROM THE RUNTIME rather than from an enqueue: the same code then serves the front
	 * end, the [ai_section] shortcode, an FSE template and the Studio preview, none of which go
	 * through the same door. Whoever adds a carousel adds a `.swiper` and nothing else — there is
	 * no list of pages to keep in step with the content.
	 */
	protected static function swiper_js() {
		$base = AIPB_URL . 'assets/vendor/swiper/swiper-bundle.min.';
		$js   = wp_json_encode( $base . 'js?v=' . AIPB_VERSION, JSON_UNESCAPED_SLASHES );
		$css  = wp_json_encode( $base . 'css?v=' . AIPB_VERSION, JSON_UNESCAPED_SLASHES );

		return <<<JS
(function(){
	if(window.__aipbSwiper){return;}
	var waiting=null; // null = never asked for; an array = loading, callbacks queued
	window.__aipbSwiper=function(cb){
		if(window.Swiper){ cb(); return; }
		if(waiting){ waiting.push(cb); return; }
		waiting=[cb];
		var left=2, done=function(){ if(--left)return; var q=waiting; waiting=[]; for(var i=0;i<q.length;i++){ try{q[i]();}catch(e){} } };
		var l=document.createElement('link'); l.rel='stylesheet'; l.href={$css}; l.onload=done; l.onerror=done;
		document.head.appendChild(l);
		var s=document.createElement('script'); s.src={$js}; s.onload=done;
		// A library that does not arrive must not take the section's own code down with it: the
		// slides stay stacked, which is a carousel that does not scroll — not a blank section.
		s.onerror=done;
		document.head.appendChild(s);
	};
})();
JS;
	}

	/* --------------------------------- Motion -------------------------------- */

	/**
	 * The scroll reveal: the TRIGGER is shared, the animation stays the section's own.
	 *
	 * WHAT IT SOLVES. Every section that wanted an entrance used to rewrite the same machinery —
	 * an IntersectionObserver, a flag so it fires once, the initial state — and it rewrote it
	 * differently each time: within one page two grids appeared with two durations and two easings,
	 * because nobody had written them together. It was also the most expensive boilerplate we
	 * generate, in the tokens that cost most (output), for code that is identical every time.
	 *
	 * WHAT IT IS NOT. It is not a catalogue of animations. `data-aura-reveal` with NO value applies
	 * no animation at all: it only puts `data-aura-in` on the element when it enters the viewport,
	 * and what happens then is written in the section's CSS, freely — a bespoke @keyframes, a
	 * clip-path, three chained transitions. The named values (up, down, left, right, fade, zoom)
	 * are there so that the ordinary case costs zero lines, not so that the extraordinary one
	 * becomes impossible.
	 *
	 * NOTHING IS EVER HIDDEN BY CSS ALONE. The initial state only applies under
	 * `[data-aura-motion]`, an attribute this script writes on <html> AFTER checking it can
	 * actually reveal. If the JS does not run — an error above, a static export, a bot — the
	 * attribute never lands and the content is simply visible. A reveal that hides content and
	 * fails to bring it back is not a broken animation, it is a blank page.
	 *
	 * THE KNOBS READ THE BRAND. `--aura-dur`, `--aura-ease`, `--aura-step` and `--aura-dist` are
	 * seeded on [data-aura-motion] from the motion tokens (--aipb-motion-*), so the default
	 * entrance IS the brand's entrance, on every page, with nobody having asked for it. A section
	 * can still retune one element by setting the knob on it — that wins by specificity — and the
	 * literal fallbacks stay where they were, for a page whose tokens block never rendered. The
	 * shipped token values equal the old hardcoded ones, so nothing moves until somebody edits
	 * a motion token — which is the moment everything is supposed to move, together.
	 */
	public static function motion_css() {
		// The presets. `left`/`right` say where the element COMES FROM.
		return <<<'CSS'
[data-aura-motion]{--aura-dur:var(--aipb-motion-duration-base,.6s);--aura-ease:var(--aipb-motion-ease,cubic-bezier(.22,.61,.36,1));--aura-step:var(--aipb-motion-stagger,80ms);--aura-dist:var(--aipb-motion-distance,24px)}
[data-aura-motion] [data-aura-reveal="fade"],[data-aura-motion] [data-aura-reveal="up"],[data-aura-motion] [data-aura-reveal="down"],[data-aura-motion] [data-aura-reveal="left"],[data-aura-motion] [data-aura-reveal="right"],[data-aura-motion] [data-aura-reveal="zoom"]{opacity:0;transition:opacity var(--aura-dur,.6s) var(--aura-ease,cubic-bezier(.22,.61,.36,1)) var(--aura-delay,0s),transform var(--aura-dur,.6s) var(--aura-ease,cubic-bezier(.22,.61,.36,1)) var(--aura-delay,0s)}
[data-aura-motion] [data-aura-reveal="up"]{transform:translateY(var(--aura-dist,24px))}
[data-aura-motion] [data-aura-reveal="down"]{transform:translateY(calc(-1 * var(--aura-dist,24px)))}
[data-aura-motion] [data-aura-reveal="left"]{transform:translateX(calc(-1 * var(--aura-dist,24px)))}
[data-aura-motion] [data-aura-reveal="right"]{transform:translateX(var(--aura-dist,24px))}
[data-aura-motion] [data-aura-reveal="zoom"]{transform:scale(.94)}
[data-aura-motion] [data-aura-reveal][data-aura-in]{opacity:1;transform:none}
[data-aura-stagger]>*{--aura-delay:calc(var(--aura-i,0) * var(--aura-step,80ms))}
@media (prefers-reduced-motion:reduce){
[data-aura-motion] [data-aura-reveal]{opacity:1;transform:none;transition:none}
.aipb-s,.aipb-s *,.aipb-s *::before,.aipb-s *::after,.aipb-page-layer,.aipb-page-layer *,.aipb-page-layer *::before,.aipb-page-layer *::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}
}
CSS;
	}

	/**
	 * The observer behind {@see motion_css()}. It ships inside the sections runtime, so the front
	 * end and the Studio preview get it from the same place.
	 *
	 * Reduced motion is handled HERE and not only in the media query: whoever asked for less
	 * movement gets `data-aura-in` immediately and no observer at all, so a bespoke animation
	 * hanging off that attribute finds its end state already reached instead of playing.
	 */
	protected static function motion_js() {
		return <<<'JS'
(function(){
	if(window.__aipbMotion){return;} window.__aipbMotion=1;
	var root=document.documentElement;
	var reduce=window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches;

	function stagger(scope){
		var cs=scope.querySelectorAll('[data-aura-stagger]');
		for(var i=0;i<cs.length;i++){
			var c=cs[i], kids=c.children, preset=c.getAttribute('data-aura-stagger')||'up';
			for(var k=0;k<kids.length;k++){
				kids[k].style.setProperty('--aura-i',k);
				// The container declares the intent; a child that already has its own reveal keeps it.
				if(!kids[k].hasAttribute('data-aura-reveal')){kids[k].setAttribute('data-aura-reveal',preset);}
			}
		}
	}
	function all(scope){ return (scope||document).querySelectorAll('[data-aura-reveal]'); }
	function reveal(el){ el.setAttribute('data-aura-in',''); }

	if(reduce||!('IntersectionObserver' in window)){
		window.__aipbReveal=function(scope){ stagger(scope||document); var e=all(scope); for(var i=0;i<e.length;i++){reveal(e[i]);} };
		window.__aipbReveal();
		return;
	}

	// Only now: from here on we know how to give back what we are about to hide.
	root.setAttribute('data-aura-motion','');

	var pending=[], ticking=false;

	function done(el){ reveal(el); io.unobserve(el); var i=pending.indexOf(el); if(i>-1){pending.splice(i,1);} }

	var io=new IntersectionObserver(function(entries){
		for(var i=0;i<entries.length;i++){
			if(entries[i].isIntersecting){ done(entries[i].target); }
		}
	// The bottom margin makes it fire a little BEFORE the element touches the edge: an entrance
	// that starts exactly on the border is read as a delay, not as an entrance.
	},{rootMargin:'0px 0px -8% 0px',threshold:0.01});

	/**
	 * The safety net, and it is not belt-and-braces: an element whose initial state CLIPS it —
	 * clip-path:inset(0 100% 0 0), the classic reveal by mask — never intersects anything, so the
	 * observer stays silent and the element remains hidden for good. Measured in Chrome, on
	 * exactly the bespoke entrance the chapter suggests writing.
	 *
	 * So the geometry has the last word: whatever the CSS does to the element, if its box has
	 * passed the viewport's edge it gets revealed. It runs only while something is still pending
	 * and unhooks itself as soon as the list empties — on a page with no reveals it costs nothing.
	 */
	function sweep(){
		ticking=false;
		for(var i=pending.length-1;i>=0;i--){
			var r=pending[i].getBoundingClientRect();
			if(r.top<innerHeight*0.92&&r.bottom>0){ done(pending[i]); }
		}
		if(!pending.length){ removeEventListener('scroll',onScroll); removeEventListener('resize',onScroll); }
	}
	function onScroll(){ if(!ticking){ ticking=true; requestAnimationFrame(sweep); } }

	window.__aipbReveal=function(scope){
		scope=scope||document;
		stagger(scope);
		var e=all(scope), added=0;
		for(var i=0;i<e.length;i++){ if(!e[i].__aura){ e[i].__aura=1; pending.push(e[i]); io.observe(e[i]); added++; } }
		if(added){
			addEventListener('scroll',onScroll,{passive:true});
			addEventListener('resize',onScroll,{passive:true});
			onScroll();
		}
	};
	window.__aipbReveal();
})();
JS;
	}

	/* ------------------------------ Page polish ------------------------------ */

	/**
	 * Final polish of the page HTML (one pass, at whole-page level): guarantees a single <h1> and
	 * improves the <img>s. Deterministic: it holds regardless of how "good" the model was.
	 */
	public static function finalize_html( $html ) {
		return self::enhance_images( self::single_h1( self::visible_h1( $html ) ) );
	}

	/**
	 * Classes that, in THIS page's CSS, hide whatever they are applied to.
	 *
	 * They are read from the real rules instead of being guessed from the names: a section can call
	 * its class `.readers-only` and hide it all the same. The well-known names stay as a safety net,
	 * for the case where the rule comes from an external stylesheet we cannot see here.
	 *
	 * `opacity:0` is deliberately NOT in the list: it is the starting state of half the entrance
	 * animations, and mistaking it for "hidden" would demote perfectly visible headings.
	 *
	 * Two filters keep the recognition CONSERVATIVE, and both come from real false positives seen on
	 * this site:
	 *  - `@media` blocks are dropped: `.grid{display:none}` inside a breakpoint means "hidden below
	 *    768px", not "hidden". Without this filter half the home page's sections, the hero included,
	 *    were marked invisible;
	 *  - only SIMPLE selectors are accepted (`.class`, or `#scope .class`). `.navbar.is-scrolled`,
	 *    `.menu:hover`, `.overlay .submenu` describe a STATE or a context, not the class itself.
	 *
	 * Better to miss a hidden text than to mistake a visible one for hidden: the first mistake
	 * leaves things as they are, the second demotes the h1 of a page that was fine.
	 */
	protected static function hidden_classes( $html ) {
		$classes = array( 'sr-only', 'visually-hidden', 'screen-reader-text', 'visuallyhidden', 'a11y-hidden' );

		if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style>#is', $html, $styles ) ) {
			return array_flip( $classes );
		}
		foreach ( $styles[1] as $css ) {
			// Out with the conditional blocks and all their content (one level of nesting, which is
			// what our CSS produces).
			$css = preg_replace( '/@(?:media|supports|container)[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/s', ' ', (string) $css );

			if ( ! preg_match_all( '#([^{}]+)\{([^}]*)\}#s', (string) $css, $rules, PREG_SET_ORDER ) ) {
				continue;
			}
			foreach ( $rules as $rule ) {
				if ( ! self::declares_hidden( $rule[2] ) ) {
					continue;
				}
				foreach ( explode( ',', $rule[1] ) as $selector ) {
					if ( preg_match( '/^\s*(?:#[\w-]+\s+)?\.([\w-]+)\s*$/', $selector, $n ) ) {
						$classes[] = $n[1];
					}
				}
			}
		}
		return array_flip( array_unique( $classes ) );
	}

	/** Do these declarations take the element off the screen (or off the reading flow)? */
	protected static function declares_hidden( $declarations ) {
		$decl = strtolower( preg_replace( '/\s+/', '', (string) $declarations ) );

		return ( false !== strpos( $decl, 'display:none' ) )
			|| ( false !== strpos( $decl, 'visibility:hidden' ) )
			|| ( false !== strpos( $decl, 'clip:rect(' ) )
			|| ( false !== strpos( $decl, 'clip-path:inset(50%)' ) )
			|| ( false !== strpos( $decl, 'font-size:0' ) )
			|| ( false !== strpos( $decl, 'width:1px' ) && false !== strpos( $decl, 'height:1px' ) );
	}

	/** Is a heading tag hidden? Looks at its classes and at its inline style. */
	protected static function heading_hidden( $attrs, $hidden_classes ) {
		if ( preg_match( '/\bstyle\s*=\s*"([^"]*)"/i', $attrs, $s ) ) {
			$decl = strtolower( preg_replace( '/\s+/', '', $s[1] ) );
			if ( false !== strpos( $decl, 'display:none' ) || false !== strpos( $decl, 'visibility:hidden' ) || false !== strpos( $decl, 'font-size:0' ) ) {
				return true;
			}
		}
		if ( ! preg_match( '/\bclass\s*=\s*"([^"]*)"/i', $attrs, $c ) ) {
			return false;
		}
		foreach ( preg_split( '/\s+/', trim( $c[1] ) ) as $one ) {
			if ( '' !== $one && isset( $hidden_classes[ $one ] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * The page's <h1> must be the title people SEE.
	 *
	 * Left to itself, the model treats the h1 as a formality separate from the design: it puts an
	 * <h1 class="sr-only"> with an invented "SEO title" and leaves the real title in an <h2>. That
	 * is hidden text written for search engines — at best it counts for nothing, at worst it is a
	 * quality signal against you — and meanwhile the page has no real h1.
	 *
	 * Nothing is deleted here: if the ONLY h1 is hidden and there is a visible h2 further down, the
	 * two swap levels. The hidden heading stays (labelling a landmark for screen readers is a
	 * legitimate use), but it stops being the page's title.
	 *
	 * Deterministic and applied at render time: it holds for already saved sections too, without
	 * having to regenerate them.
	 */
	public static function visible_h1( $html ) {
		if ( false === stripos( $html, '<h1' ) ) {
			return $html;
		}

		// Every heading, in document order, with its position and visibility.
		if ( ! preg_match_all( '#<(h[12])\b([^>]*)>#i', $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) {
			return $html;
		}
		$hidden_classes = self::hidden_classes( $html );

		$h1s      = array();
		$first_h2 = null;
		foreach ( $m as $tag ) {
			$level  = strtolower( $tag[1][0] );
			$hidden = self::heading_hidden( $tag[2][0], $hidden_classes );
			if ( 'h1' === $level ) {
				$h1s[] = array( 'pos' => $tag[0][1], 'len' => strlen( $tag[0][0] ), 'hidden' => $hidden );
			} elseif ( null === $first_h2 && ! $hidden ) {
				$first_h2 = array( 'pos' => $tag[0][1], 'len' => strlen( $tag[0][0] ), 'attrs' => $tag[2][0] );
			}
		}

		// We only step in if every h1 on the page is hidden AND there is a visible h2 to promote.
		if ( ! $h1s || null === $first_h2 ) {
			return $html;
		}
		foreach ( $h1s as $x ) {
			if ( ! $x['hidden'] ) {
				return $html; // a visible h1 is already there: nothing to touch
			}
		}

		// Replacements from the end, so the computed positions stay valid.
		$edits = array();
		foreach ( $h1s as $x ) {
			$edits[] = array( $x['pos'], $x['len'], '<h2', true );
		}
		$edits[] = array( $first_h2['pos'], $first_h2['len'], '<h1' . $first_h2['attrs'] . '>', false );
		usort( $edits, function ( $a, $b ) { return $b[0] - $a[0]; } );

		foreach ( $edits as $e ) {
			$opening = substr( $html, $e[0], $e[1] );
			$html    = substr_replace( $html, $e[3] ? '<h2' . substr( $opening, 3 ) : $e[2], $e[0], $e[1] );
			// The matching closing tag: the first one of the same level after the opening.
			$old   = $e[3] ? '</h1>' : '</h2>';
			$new   = $e[3] ? '</h2>' : '</h1>';
			$close = stripos( $html, $old, $e[0] );
			if ( false !== $close ) {
				$html = substr_replace( $html, $new, $close, strlen( $old ) );
			}
		}
		return $html;
	}

	/**
	 * One <h1> per page (SEO + a11y): the first stays an <h1>, the following ones are demoted to
	 * <h2>. Since pages are generated one section at a time, without this there is no guarantee the
	 * model produces only one.
	 */
	public static function single_h1( $html ) {
		if ( false === stripos( $html, '<h1' ) ) {
			return $html;
		}
		$o    = 0;
		$html = preg_replace_callback( '#<h1\b#i', function () use ( &$o ) {
			$o++;
			return 1 === $o ? '<h1' : '<h2';
		}, $html );
		$c    = 0;
		$html = preg_replace_callback( '#</h1>#i', function () use ( &$c ) {
			$c++;
			return 1 === $c ? '</h1>' : '</h2>';
		}, $html );
		return $html;
	}

	public static function enhance_images( $html ) {
		if ( false === strpos( $html, '<img' ) ) {
			return $html;
		}
		$first = true;
		$html  = preg_replace_callback( '/<img\b([^>]*?)\/?>/i', function ( $m ) use ( &$first ) {
			$attrs = $m[1];
			$add   = '';
			if ( ! preg_match( '/\balt\s*=/i', $attrs ) ) {
				$add .= ' alt=""';
			}
			if ( ! preg_match( '/\bdecoding\s*=/i', $attrs ) ) {
				$add .= ' decoding="async"';
			}
			if ( ! preg_match( '/\bloading\s*=/i', $attrs ) && ! preg_match( '/\bfetchpriority\s*=/i', $attrs ) ) {
				$add .= $first ? ' fetchpriority="high"' : ' loading="lazy"';
			}

			// Dimensions: without them the browser does not know how much room to reserve and the
			// page jumps while loading (layout shift). The model adds them when it remembers; here
			// they are taken from the file, the only source that cannot be wrong. Only if BOTH are
			// missing: one of the two alone, or hand-written values, belong to whoever wrote them.
			if ( ! preg_match( '/\bwidth\s*=/i', $attrs ) && ! preg_match( '/\bheight\s*=/i', $attrs ) ) {
				$dim = self::image_size( $attrs );
				if ( $dim ) {
					$add .= ' width="' . $dim[0] . '" height="' . $dim[1] . '"';
				}
			}

			// Srcset from Bootstrap breakpoints, only when the template did not already write one.
			// Handed the attrs PLUS the width just added, so the ceiling is the file's real size.
			if ( class_exists( 'AIPB_Srcset' ) ) {
				$add .= AIPB_Srcset::img_attrs( $attrs . $add );
			}

			$first = false;
			return '<img' . $attrs . $add . '>';
		}, $html );

		// WebP <picture> AFTER the polish: the JPEG fallback has to carry width/height/alt already,
		// and wrap() never encodes — missing siblings are queued for after the response.
		return class_exists( 'AIPB_WebP' ) ? AIPB_WebP::wrap( $html ) : $html;
	}

	/**
	 * The REAL width and height of the file in an <img>, or null when they cannot be known.
	 *
	 * Two routes, cheapest first: WordPress's variants carry the dimensions in the file name
	 * (`photo-1200x800.jpg`) and need no query at all; for originals it goes through the Media
	 * Library, with AIPB_Template_Engine's cache. An image outside the site gives nothing, and that
	 * is fine: better no attribute than an invented one.
	 *
	 * SVGs are left out: they have no intrinsic pixel dimensions, and declaring fake ones is worse
	 * than saying nothing.
	 *
	 * @return array{0:int,1:int}|null
	 */
	protected static function image_size( $attrs ) {
		if ( ! preg_match( '/\bsrc\s*=\s*"([^"]+)"/i', $attrs, $m ) ) {
			return null;
		}
		$src = html_entity_decode( trim( $m[1] ), ENT_QUOTES );
		if ( '' === $src || 0 === strpos( $src, 'data:' ) || preg_match( '/\.svg(\?|#|$)/i', $src ) ) {
			return null;
		}

		if ( preg_match( '/-(\d+)x(\d+)\.[a-z0-9]+(\?|#|$)/i', $src, $d ) ) {
			return array( (int) $d[1], (int) $d[2] );
		}
		if ( ! class_exists( 'AIPB_Template_Engine' ) ) {
			return null;
		}
		$id = AIPB_Template_Engine::attachment_id( $src );
		if ( ! $id ) {
			return null;
		}
		$meta = wp_get_attachment_image_src( $id, 'full' );
		return ( $meta && ! empty( $meta[1] ) && ! empty( $meta[2] ) ) ? array( (int) $meta[1], (int) $meta[2] ) : null;
	}

	/** A page's saved blocks (empty array when it is not a block page). */
	protected static function get_blocks( $id ) {
		$raw = get_post_meta( $id, AIPB_META_BLOCKS, true );
		if ( ! $raw ) {
			return array();
		}
		$blocks = json_decode( $raw, true );
		return is_array( $blocks ) ? $blocks : array();
	}

	/* --------------------- Snapshot into post_content ------------------------ */

	/**
	 * Compiles the HTML from the meta for the SNAPSHOT in post_content (the fallback for when the
	 * plugin is deactivated, and the source SEO plugins analyse). It uses the same LIGHT DOM wrap as
	 * the frontend: markup in the document (indexable), CSS scoped per section, <script>s made inert
	 * (the snapshot is static: with no runtime active they stay harmless).
	 *
	 * It handles both shapes:
	 *  - a stack of blocks (pages and FSE templates): inline blocks and references, concatenated;
	 *  - a single template (pattern/component): one render.
	 */
	public static function compile_html( $id ) {
		$blocks = self::get_blocks( $id );

		if ( $blocks ) {
			$blocks = self::content_blocks( $blocks );

			return $blocks ? self::render_blocks( $blocks, false ) : '';
		}

		return self::render_single( $id, false );
	}

	/* ------------------- FSE templates: rendered live ----------------------- */

	/**
	 * A template is not content: it is a SHAPE to be filled on every request.
	 *
	 * WordPress draws block templates from their post_content, where we keep the SNAPSHOT of the
	 * last save (the previews need it, see sync_post_content). For a page built out of static
	 * sections that was fine; for a template it is not: the post.* placeholders and the loops would
	 * be resolved ONCE, when saving in the Studio, where there is no product to show. The result is
	 * what you see on the page — empty title, broken image, missing date — while the template is
	 * written correctly.
	 *
	 * Here, right after WordPress has worked out which template to use, we replace its content with
	 * the call to our renderer, which runs ON EVERY REQUEST with the real query in front of it. The
	 * snapshot stays where it is: it goes on serving the previews and whoever deactivates the plugin.
	 */
	public static function live_block_template( $template ) {
		global $_wp_current_template_id, $_wp_current_template_content;

		if ( empty( $_wp_current_template_id ) || ! function_exists( 'get_block_template' ) ) {
			return $template;
		}
		// On a canvas page the theme's template is not even used.
		if ( is_singular() && self::use_canvas( get_queried_object_id() ) ) {
			return $template;
		}

		$tpl = get_block_template( (string) $_wp_current_template_id, 'wp_template' );
		$pid = ( $tpl && ! empty( $tpl->wp_id ) ) ? (int) $tpl->wp_id : 0;
		if ( $pid && self::is_aura_built( $pid ) ) {
			$_wp_current_template_content = self::live_content( $pid );
		}

		return $template;
	}

	/**
	 * Same thing for the template PARTS (header, footer) built with Aura: the core/template-part
	 * block renders them by reading the entity's content, so that is where we step in.
	 */
	public static function live_template_part( $block_template, $id, $template_type ) {
		if ( 'wp_template_part' !== $template_type || ! $block_template || empty( $block_template->wp_id ) ) {
			return $block_template;
		}
		if ( self::is_aura_built( (int) $block_template->wp_id ) ) {
			$block_template->content = self::live_content( (int) $block_template->wp_id );
		}
		return $block_template;
	}

	/**
	 * ID of the block template BUILT WITH AURA that is drawing this request, 0 when there is none.
	 * It is needed outside rendering too: a page drawn by an Aura template has to be dressed like an
	 * Aura page, not like a theme page (see AIPB_Assets).
	 */
	public static function aura_template_id() {
		static $memo = null;
		if ( null !== $memo ) {
			return $memo;
		}
		$memo = 0;

		global $_wp_current_template_id;
		if ( is_admin() || empty( $_wp_current_template_id ) || ! function_exists( 'get_block_template' ) ) {
			return $memo;
		}
		if ( is_singular() && self::use_canvas( get_queried_object_id() ) ) {
			return $memo; // the theme's template is not used at all
		}

		$tpl = get_block_template( (string) $_wp_current_template_id, 'wp_template' );
		$pid = ( $tpl && ! empty( $tpl->wp_id ) ) ? (int) $tpl->wp_id : 0;
		if ( $pid && self::is_aura_built( $pid ) ) {
			$memo = $pid;
		}
		return $memo;
	}

	/**
	 * Does the Aura template embed the content's BODY ({{h:post.content}})?
	 *
	 * It changes how the page has to be isolated: if the product's or the post's Gutenberg blocks
	 * end up inside it, the blocks' CSS is still needed and removing it would strip them bare.
	 */
	public static function template_embeds_content( $post_id ) {
		foreach ( (array) self::get_blocks( (int) $post_id ) as $b ) {
			if ( false !== strpos( (string) ( $b['template'] ?? '' ), 'post.content' ) ) {
				return true;
			}
		}
		return false;
	}

	/** Does the content really have Aura sections to render? */
	protected static function is_aura_built( $post_id ) {
		if ( ! class_exists( 'AIPB_Ownership' ) || ! AIPB_Ownership::is_aura( $post_id ) ) {
			return false;
		}
		return ! empty( self::get_blocks( $post_id ) ) || '' !== (string) get_post_meta( $post_id, AIPB_META_TEMPLATE, true );
	}

	/**
	 * Dynamic block that hands over to our renderer.
	 *
	 * It exists for this alone: giving WordPress something that, inside a template, calls us at the
	 * right moment. It is NOT a block to be inserted by hand in the editor, and indeed it has no
	 * interface.
	 *
	 * Why not the core/shortcode block, which would have been the obvious shortcut: its
	 * render_callback is `return wpautop( $content )`. On a section made of <style> and <script>
	 * wpautop is devastating — it slips </p><p> inside the JavaScript, which stops compiling and
	 * dies silently. It is the same reason render_content() runs at priority 99 on the_content.
	 */
	public static function register_section_block() {
		if ( ! function_exists( 'register_block_type' ) || WP_Block_Type_Registry::get_instance()->is_registered( 'aipb/section' ) ) {
			return;
		}
		register_block_type( 'aipb/section', array(
			'attributes'      => array(
				'id' => array( 'type' => 'number', 'default' => 0 ),
			),
			'render_callback' => array( __CLASS__, 'render_section_block' ),
		) );
	}

	public static function render_section_block( $attributes ) {
		// The block carries the ID of the content it draws; on a multilingual site, the ID of the
		// content in the language being served.
		$id   = AIPB_WPML::object_id( isset( $attributes['id'] ) ? (int) $attributes['id'] : 0 );
		$html = $id ? self::render_post( $id ) : '';
		return $html ? self::finalize_html( $html ) : '';
	}

	/** The content of an Aura template: the call to our renderer, and nothing else. */
	protected static function live_content( $post_id ) {
		return '<!-- wp:aipb/section {"id":' . (int) $post_id . '} /-->';
	}

	/**
	 * Mirrors the content built by the Studio into post_content, as a self-sufficient core/html
	 * block (--aipb-* tokens and fonts inline).
	 *
	 * It serves two purposes, both consequences of the fact that WordPress consumes post_content:
	 *  1. The preview in the Patterns grid and in the Site Editor (otherwise "Empty pattern").
	 *  2. Rendering the FSE templates (wp_template/part): WP draws them from their blocks, it does
	 *     not go through the_content, so the snapshot IS the published template.
	 *
	 * The source of truth stays in the meta; post_content is a derived artefact, regenerated on
	 * every save. It is a SNAPSHOT: dynamic WP data is resolved at save time.
	 *
	 * Shortcodes are the exception and stay LITERAL: freezing a contact form (with its nonce) in
	 * here would make no sense, whereas leaving them written is how the plugins that scan
	 * post_content decide whether to enqueue their own assets — and that is how that form's CSS
	 * reaches the page. Whoever really consumes post_content (the_content, plugin deactivated)
	 * applies do_shortcode to it on their own account anyway.
	 */
	public static function sync_post_content( $id ) {
		$html = self::compile_html( $id );
		if ( '' === $html ) {
			return;
		}
		$html = self::finalize_html( $html );
		// STATIC snapshot: the inert <script>s are useless (there is no runtime when the plugin is
		// deactivated) and would weigh down post_content / the SEO analysis. Out they go. The
		// <style>s stay.
		$html = preg_replace( '#<script\b[^>]*\bdata-aipb\b[^>]*>.*?</script>#is', '', $html );

		$content = "<!-- wp:html -->\n" . self::snapshot_head() . "\n" . $html . "\n<!-- /wp:html -->";

		$update = array(
			'ID'           => (int) $id,
			'post_content' => wp_slash( $content ),
		);

		// The page's summary. It is for whoever reads post_content to describe it — the SEO plugins
		// first of all: without it, Yoast fishes from the start of the snapshot, which is the header,
		// and a bakery's og:description becomes the list of menu entries (measured: «About us The
		// Bakeries … Open the menu Close the menu …»).
		//
		// We stay on OUR side of the boundary: we write no meta tag, we touch neither title nor
		// canonical. We only give whoever does that job a decent raw material.
		$excerpt = self::summarize( $html );
		if ( '' !== $excerpt && self::excerpt_is_ours( $id, $html ) ) {
			$update['post_excerpt'] = wp_slash( $excerpt );
			update_post_meta( $id, '_aipb_excerpt', $excerpt );
		}

		// The builder's user has unfiltered_html: the <script>/<style> survive intact.
		// It avoids the save_post -> sync cycle: none of our hooks re-enters here.
		wp_update_post( $update );
	}

	/**
	 * The tokens and fonts that make the snapshot self-sufficient.
	 *
	 * Self-hosted fonts in the static snapshot too: the local files stay valid with the plugin
	 * deactivated, so the degraded page does not call Google (GDPR). Fallback: @import.
	 */
	protected static function snapshot_head() {
		$head  = '<style>' . AIPB_Design::css_variables() . '</style>';
		$fonts = AIPB_Design::google_fonts_url();
		if ( ! $fonts ) {
			return $head;
		}

		$local = AIPB_Fonts::local_css();

		return ( '' !== $local ? '<style>' . $local . '</style>' : '<style>@import url("' . esc_url_raw( $fonts ) . '");</style>' ) . $head;
	}

	/**
	 * HTML → readable text.
	 *
	 * Tags become a SPACE instead of disappearing: wp_strip_all_tags() simply removes them, and
	 * `<a>About us</a><a>Products</a>` comes out as «About usProducts» — words welded together that
	 * then match nothing. Punctuation left dangling is reattached to its word.
	 */
	protected static function to_text( $html ) {
		$html = preg_replace( '#<(style|script|template)\b[^>]*>.*?</\1>#is', ' ', (string) $html );
		$html = preg_replace( '#<[^>]+>#', ' ', $html );
		// A shortcode that has not been expanded is code, not text: «[contact_form id="2786"]» as
		// the description of a contact page is exactly what nobody wants to read in a SERP.
		// strip_shortcodes() only knows the registered ones; the regex catches the others too.
		$html = strip_shortcodes( (string) $html );
		$html = preg_replace( '#\[/?[a-z][a-z0-9_-]*(?:\s[^\]]*)?\]#i', ' ', (string) $html );
		$text = html_entity_decode( (string) $html, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = preg_replace( '/\s+/u', ' ', $text );
		$text = preg_replace( '/\s+([,.;:!?%)\]])/u', '$1', $text );
		$text = preg_replace( '/([(\[])\s+/u', '$1', $text );
		return trim( (string) $text );
	}

	/**
	 * Can we rewrite this page's summary, or did a person write it?
	 *
	 * Three cases where it is ours to touch:
	 *  - there is none;
	 *  - it is the one we wrote last time (`_aipb_excerpt`), so it has to be refreshed along with
	 *    the page, otherwise it describes a version that no longer exists;
	 *  - it is an AUTOMATIC excerpt, recognisable because it is made of the page's CHROME: menu,
	 *    navigation entries, button labels. This third case is not theoretical: before this
	 *    function, summaries were born from the start of the snapshot — which begins with the
	 *    header — and on this site they all read «About us The Bakeries … Open the menu». Treating
	 *    them as "hand-written" would have frozen forever the very defect to be fixed.
	 *
	 * The comparison looks at the VOCABULARY and not at the start of the text, because those
	 * excerpts are often old: they describe a version of the page that no longer exists, and a
	 * prefix test would not recognise them. The menu's words, on the other hand, stay the same.
	 *
	 * Everything else belongs to whoever wrote it and is left alone.
	 */
	protected static function excerpt_is_ours( $id, $html ) {
		$current = trim( (string) get_post_field( 'post_excerpt', $id ) );
		if ( '' === $current ) {
			return true;
		}
		if ( $current === trim( (string) get_post_meta( $id, '_aipb_excerpt', true ) ) ) {
			return true;
		}

		$words = function ( $s ) {
			$s = self::to_text( $s );
			$s = function_exists( 'mb_strtolower' ) ? mb_strtolower( $s ) : strtolower( $s );
			preg_match_all( '/[\p{L}\p{N}]{4,}/u', $s, $m );
			return $m[0];
		};

		$theirs = $words( $current );
		if ( count( $theirs ) < 6 ) {
			return false; // too short to judge: when in doubt it belongs to whoever wrote it
		}

		// The chrome's vocabulary: THIS page's header, nav and footer.
		$chrome = '';
		if ( preg_match_all( '#<(header|nav|footer)\b[^>]*>(.*?)</\1>#is', (string) $html, $mm, PREG_SET_ORDER ) ) {
			foreach ( $mm as $x ) {
				$chrome .= ' ' . $x[2];
			}
		}
		if ( '' === trim( $chrome ) ) {
			return false;
		}
		$vocabulary = array_flip( $words( $chrome ) );

		$from_chrome = 0;
		foreach ( $theirs as $w ) {
			if ( isset( $vocabulary[ $w ] ) ) {
				$from_chrome++;
			}
		}
		// Above 70% it is no longer a lexical coincidence: it is the menu, trimmed.
		return ( $from_chrome / count( $theirs ) ) >= 0.7;
	}

	/**
	 * A summary of the page, taken from the CONTENT and not from the chrome.
	 *
	 * We skip <header>, <nav> and <footer> — which in an Aura page are sections like any other and
	 * sit at the top — plus <style>, <script> and <template>. What is left is the text the page
	 * really says, cut at a word boundary.
	 */
	protected static function summarize( $html, $limit = 200 ) {
		// HIDDEN text does not describe the page: the reader has never seen it. It has to go before
		// the cut, otherwise a screen-reader label — or the remains of an old "SEO title" demoted by
		// visible_h1() — takes the summary's first words.
		foreach ( array_keys( self::hidden_classes( (string) $html ) ) as $class ) {
			$html = preg_replace(
				'#<([a-z][a-z0-9]*)\b[^>]*\bclass\s*=\s*"[^"]*\b' . preg_quote( $class, '#' ) . '\b[^"]*"[^>]*>.*?</\1>#is',
				' ',
				(string) $html
			);
		}

		$html = preg_replace( '#<(header|nav|footer|style|script|template)\b[^>]*>.*?</\1>#is', ' ', (string) $html );
		$text = self::to_text( $html );
		if ( '' === $text ) {
			return '';
		}
		if ( function_exists( 'mb_strlen' ) && mb_strlen( $text ) <= $limit ) {
			return $text;
		}

		$cut   = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $limit ) : substr( $text, 0, $limit );
		$space = strrpos( $cut, ' ' );
		if ( false !== $space && $space > (int) ( $limit * 0.6 ) ) {
			$cut = substr( $cut, 0, $space );
		}
		return rtrim( $cut, " \t\n\r\0\x0B,;:.-" ) . '…';
	}
}
