PHP: TemplateCompilerTest

File: tests/php/unit/TemplateCompilerTest.php Tests: Four private methods in includes/class-sanilwb-template-compiler.php


What the Template Compiler does

When a template is saved in the Template Builder, SANILWB_Template_Compiler converts the node tree (stored as schema_json in the DB) into a compiled PHP+CSS file on disk. That file is what the frontend loads — it is the output the visitor's browser actually receives.

Because the compiler runs at save time and its output goes directly to the frontend, bugs here affect every page that uses the template. The tests cover the four methods most likely to produce incorrect or unsafe output.


sanitize_css_value

This is a public static method — the only one in this file with a public signature.

What it does: Strips any character not in the CSS value allowlist before a value is written into the compiled CSS file.

Why it matters: Template field values come from the WordPress admin, but they still pass through a compiler that writes raw CSS. A value like javascript:alert(1) inside a CSS url() is a CSS injection vector. A value like <script> can escape into surrounding HTML in certain rendering contexts.

Tests

Valid hex color passes through unchanged:

SANILWB_Template_Compiler::sanitize_css_value( '#ff0000' )
// → '#ff0000'

Colon is stripped — prevents javascript: protocol injection:

$result = SANILWB_Template_Compiler::sanitize_css_value( 'javascript:alert(1)' );
// $result does not contain ':'

Angle brackets and quotes are stripped — prevents XSS via CSS injection:

$result = SANILWB_Template_Compiler::sanitize_css_value( '<script>"evil"</script>' );
// $result does not contain '<', '>', or '"'

CSS custom properties pass through unchanged:

SANILWB_Template_Compiler::sanitize_css_value( 'var(--color-primary)' )
// → 'var(--color-primary)'

CSS variables are used extensively by the Theme Options system. If -- or ( or ) were stripped, every color token would stop working in compiled templates.


build_border_declarations

What it does: Converts the border-related node fields into CSS declaration strings. Handles both per-side widths and border-radius shorthand.

Tests

Four border-width declarations from per-side inputs:

$node = [
    'sanilwb_border_width_top'    => 1,
    'sanilwb_border_width_right'  => 2,
    'sanilwb_border_width_bottom' => 3,
    'sanilwb_border_width_left'   => 4,
    'sanilwb_border_style'        => 'solid',
    'sanilwb_border_color'        => '#000',
];

// Result contains:
// border-top-width:1px
// border-right-width:2px
// border-bottom-width:3px
// border-left-width:4px

Per-side borders are a common design pattern. If the method collapsed them into a shorthand border-width: 1px 2px 3px 4px, it would still look correct most of the time — but per-side border-style and border-color overrides would become much harder to manage.

Empty node returns an empty string:

$result = $this->callPrivate( 'build_border_declarations', [ [] ] );
// → ''

Most nodes have no border. The method must return an empty string — not border-width:0px or any default — so that nodes inherit the browser's default border behaviour rather than having one forced on them.

Border-radius shorthand from top/bottom values:

$node = [
    'sanilwb_border_radius_top'    => 8,
    'sanilwb_border_radius_bottom' => 4,
];

// Result contains: border-radius:8px 8px 4px 4px

The Template Builder uses a simplified radius model: one value for both top corners, one value for both bottom corners. The four-value CSS shorthand format is TL TR BR BL. So top=8, bottom=4 becomes 8px 8px 4px 4px (top-left, top-right, bottom-right, bottom-left).


apply_breakpoint_overrides

What it does: Takes a node's full field array and a breakpoint suffix (__tablet or __mobile), and returns a new field array where base keys are overridden by their suffixed equivalents. The suffixed keys are removed from the result so the caller can use the returned array without checking for suffixes.

This is the PHP-side equivalent of getResponsiveValue in the JavaScript shared utils. The same fallback chain applies: mobile → tablet → desktop.

Tests

__tablet suffix overrides the desktop base, and is removed from the result:

$node = [ 'gap' => '10', 'gap__tablet' => '5' ];

$result = $this->callPrivate( 'apply_breakpoint_overrides', [ $node, '__tablet' ] );

// $result['gap'] === '5'
// $result does not have key 'gap__tablet'

Mobile falls back to the __tablet value when __mobile is not set:

$node = [ 'gap' => '10', 'gap__tablet' => '5' ];
// No gap__mobile.

$result = $this->callPrivate( 'apply_breakpoint_overrides', [ $node, '__mobile' ] );

// $result['gap'] === '5'   ← uses tablet value, not desktop base

This is the critical edge case. Without the tablet fallback for mobile, a node styled on tablet would revert to the desktop value on mobile — exactly the same bug the JS getResponsiveValue test guards against, but in the compiled CSS file.

A key with no breakpoint suffix is returned unchanged:

$node   = [ 'direction' => 'row' ];
$result = $this->callPrivate( 'apply_breakpoint_overrides', [ $node, '__tablet' ] );

// $result['direction'] === 'row'

collect_fonts

What it does: Walks the node tree recursively and collects every font family referenced by any node, along with the weights and styles used. The result feeds into the Google Fonts downloader — only fonts that are actually used in a template are downloaded.

Brain\Monkey stubs sanitize_text_field() to return its input unchanged so the test focuses on the traversal logic, not WP sanitisation.

Tests

Fonts from nested nodes are collected recursively:

$nodes = [[
    'type'                => 'div',
    'heading-font-family' => 'Roboto',
    'heading-font-weight' => 700,
    'heading-font-style'  => 'normal',
    'children'            => [[
        'type'                => 'excerpt',
        'excerpt-font-family' => 'Lato',
        'excerpt-font-weight' => 400,
        'excerpt-font-style'  => 'italic',
    ]],
]];

// After collect_fonts():
// $fonts['Roboto']['normal'] contains 700
// $fonts['Lato']['italic'] contains 400

The node tree can be arbitrarily deep — a div can contain a div that contains a text node. The function must recurse into children to collect fonts from all levels, not just the top level.

Nodes without a font-family key leave $fonts empty:

$nodes = [[ 'type' => 'image', 'width' => 300 ]];

// After collect_fonts():
// $fonts === []

Not every node type uses a font — image, spacer, and divider nodes have no typography settings. The function must skip them cleanly rather than adding empty or null entries to $fonts.