@stratawp/cli 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -5
- package/templates/advanced-theme/.stylelintignore +3 -0
- package/templates/advanced-theme/e2e/smoke/layout.spec.ts +11 -0
- package/templates/advanced-theme/package.json +7 -1
- package/templates/advanced-theme/playwright.smoke.config.ts +3 -0
- package/templates/advanced-theme/stylelint.config.js +1 -0
- package/templates/advanced-theme/vendor/stratawp/core/src/Components/ImageSizes.php +109 -0
- package/templates/basic-theme/.stylelintignore +3 -0
- package/templates/basic-theme/e2e/smoke/layout.spec.ts +11 -0
- package/templates/basic-theme/package.json +7 -1
- package/templates/basic-theme/playwright.smoke.config.ts +3 -0
- package/templates/basic-theme/stylelint.config.js +1 -0
- package/templates/basic-theme/vendor/stratawp/core/src/Components/ImageSizes.php +109 -0
- package/templates/store-theme/.stylelintignore +3 -0
- package/templates/store-theme/e2e/smoke/layout.spec.ts +11 -0
- package/templates/store-theme/package.json +7 -1
- package/templates/store-theme/playwright.smoke.config.ts +3 -0
- package/templates/store-theme/src/scss/_woocommerce.scss +3 -3
- package/templates/store-theme/stylelint.config.js +1 -0
- package/templates/store-theme/vendor/stratawp/core/src/Components/ImageSizes.php +109 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratawp/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "CLI tool for StrataWP - create and manage WordPress themes",
|
|
5
5
|
"author": "Jon Imms",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"simple-git": "^3.22.0",
|
|
65
65
|
"ssh2-sftp-client": "^10.0.3",
|
|
66
66
|
"validate-npm-package-name": "^5.0.0",
|
|
67
|
-
"@stratawp/
|
|
68
|
-
"@stratawp/
|
|
67
|
+
"@stratawp/sync": "^0.1.1",
|
|
68
|
+
"@stratawp/explorer": "^0.7.1"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@types/adm-zip": "^0.5.7",
|
|
@@ -74,12 +74,16 @@
|
|
|
74
74
|
"@types/semver": "^7.5.8",
|
|
75
75
|
"@types/validate-npm-package-name": "^4.0.2",
|
|
76
76
|
"@vitest/coverage-v8": "^1.0.4",
|
|
77
|
+
"stylelint": "^16.0.0",
|
|
77
78
|
"tsup": "^8.0.1",
|
|
78
79
|
"typescript": "^5.3.3",
|
|
79
|
-
"vitest": "^1.0.4"
|
|
80
|
+
"vitest": "^1.0.4",
|
|
81
|
+
"@stratawp/stylelint-config": "0.1.0"
|
|
80
82
|
},
|
|
81
83
|
"templateDependencies": {
|
|
82
|
-
"@stratawp/vite-plugin": "^0.
|
|
84
|
+
"@stratawp/vite-plugin": "^0.3.0",
|
|
85
|
+
"@stratawp/stylelint-config": "^0.1.0",
|
|
86
|
+
"@stratawp/testing": "^0.2.0"
|
|
83
87
|
},
|
|
84
88
|
"scripts": {
|
|
85
89
|
"dev": "tsup --watch",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
test('layout renders header, content and footer', async ({ page }) => {
|
|
4
|
+
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
|
5
|
+
await expect(page.getByRole('banner')).toBeVisible()
|
|
6
|
+
await expect(page.locator('main').first()).toBeVisible()
|
|
7
|
+
await expect(page.getByRole('contentinfo')).toBeVisible()
|
|
8
|
+
// Patterns that fail to resolve render nothing, silently.
|
|
9
|
+
const text = (await page.locator('main').first().innerText()).trim()
|
|
10
|
+
expect(text.length).toBeGreaterThan(0)
|
|
11
|
+
})
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"build": "vite build",
|
|
11
11
|
"preview": "vite preview",
|
|
12
12
|
"ai:setup": "node scripts/ai-setup.mjs",
|
|
13
|
-
"ai:check": "pnpm build"
|
|
13
|
+
"ai:check": "pnpm build",
|
|
14
|
+
"lint:css": "stylelint \"src/**/*.{css,scss}\"",
|
|
15
|
+
"test:e2e": "playwright test --config playwright.smoke.config.ts"
|
|
14
16
|
},
|
|
15
17
|
"dependencies": {
|
|
16
18
|
"@wordpress/block-editor": "^12.19.0",
|
|
@@ -23,9 +25,13 @@
|
|
|
23
25
|
"react-dom": "^18.2.0"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
28
|
+
"@playwright/test": "^1.58.2",
|
|
29
|
+
"@stratawp/stylelint-config": "workspace:*",
|
|
30
|
+
"@stratawp/testing": "workspace:*",
|
|
26
31
|
"@stratawp/vite-plugin": "workspace:*",
|
|
27
32
|
"@vitejs/plugin-react": "^4.2.1",
|
|
28
33
|
"sass": "^1.94.2",
|
|
34
|
+
"stylelint": "^16.0.0",
|
|
29
35
|
"typescript": "^5.3.3",
|
|
30
36
|
"vite": "^5.0.10"
|
|
31
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@stratawp/stylelint-config'
|
|
@@ -29,6 +29,115 @@ class ImageSizes implements ComponentInterface {
|
|
|
29
29
|
add_filter( 'wp_get_attachment_image_attributes', array( $this, 'filter_post_thumbnail_sizes_attr' ), 10, 3 );
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Raster extensions the build pipeline produces AVIF/WebP siblings for.
|
|
34
|
+
*
|
|
35
|
+
* @var string[]
|
|
36
|
+
*/
|
|
37
|
+
private const RASTER_EXTENSIONS = array( 'jpg', 'jpeg', 'png' );
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sibling formats in preference order (best compression first).
|
|
41
|
+
*
|
|
42
|
+
* @var array<string, string>
|
|
43
|
+
*/
|
|
44
|
+
private const SIBLING_TYPES = array(
|
|
45
|
+
'avif' => 'image/avif',
|
|
46
|
+
'webp' => 'image/webp',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attribute names the caller may not set (the helper owns them).
|
|
51
|
+
*
|
|
52
|
+
* @var string[]
|
|
53
|
+
*/
|
|
54
|
+
private const RESERVED_ATTRIBUTES = array( 'src', 'srcset', 'alt', 'type' );
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find AVIF/WebP siblings of a theme image that exist on disk.
|
|
58
|
+
*
|
|
59
|
+
* @param string $relative_path Path relative to the theme directory (e.g. `dist/images/hero.jpg`).
|
|
60
|
+
* @return array<int, array{type: string, srcset: string}> Sources, AVIF first.
|
|
61
|
+
*/
|
|
62
|
+
public function get_picture_sources( string $relative_path ): array {
|
|
63
|
+
$extension = strtolower( pathinfo( $relative_path, PATHINFO_EXTENSION ) );
|
|
64
|
+
|
|
65
|
+
if ( ! $this->is_safe_path( $relative_path ) || ! in_array( $extension, self::RASTER_EXTENSIONS, true ) ) {
|
|
66
|
+
return array();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$base = substr( $relative_path, 0, -( strlen( $extension ) + 1 ) );
|
|
70
|
+
$sources = array();
|
|
71
|
+
|
|
72
|
+
foreach ( self::SIBLING_TYPES as $sibling_extension => $mime_type ) {
|
|
73
|
+
$sibling = $base . '.' . $sibling_extension;
|
|
74
|
+
|
|
75
|
+
if ( file_exists( get_theme_file_path( $sibling ) ) ) {
|
|
76
|
+
$sources[] = array(
|
|
77
|
+
'type' => $mime_type,
|
|
78
|
+
'srcset' => get_theme_file_uri( $sibling ),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return $sources;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render a theme image as `<picture>` with AVIF/WebP sources when available.
|
|
88
|
+
*
|
|
89
|
+
* @param string $relative_path Path relative to the theme directory.
|
|
90
|
+
* @param string $alt Alternative text.
|
|
91
|
+
* @param array<string,string> $attributes Extra `<img>` attributes (names must be lowercase, no `on*` handlers).
|
|
92
|
+
* @return string Markup, or an empty string when the path is unsafe or the original is missing.
|
|
93
|
+
*/
|
|
94
|
+
public function render_picture( string $relative_path, string $alt, array $attributes = array() ): string {
|
|
95
|
+
if ( ! $this->is_safe_path( $relative_path ) || ! file_exists( get_theme_file_path( $relative_path ) ) ) {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
$attribute_html = '';
|
|
100
|
+
foreach ( $attributes as $name => $value ) {
|
|
101
|
+
$name = (string) $name;
|
|
102
|
+
|
|
103
|
+
if ( 1 !== preg_match( '/^[a-z][a-z0-9-]*$/', $name )
|
|
104
|
+
|| 0 === strpos( $name, 'on' )
|
|
105
|
+
|| in_array( $name, self::RESERVED_ATTRIBUTES, true )
|
|
106
|
+
) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$attribute_html .= sprintf( ' %s="%s"', $name, esc_attr( (string) $value ) );
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$sources = '';
|
|
114
|
+
foreach ( $this->get_picture_sources( $relative_path ) as $source ) {
|
|
115
|
+
$sources .= sprintf( '<source type="%s" srcset="%s">', esc_attr( $source['type'] ), esc_url( $source['srcset'] ) );
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
$image = sprintf(
|
|
119
|
+
'<img src="%s" alt="%s"%s>',
|
|
120
|
+
esc_url( get_theme_file_uri( $relative_path ) ),
|
|
121
|
+
esc_attr( $alt ),
|
|
122
|
+
$attribute_html
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return '' === $sources ? $image : '<picture>' . $sources . $image . '</picture>';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reject empty, absolute, NUL-containing, or traversing paths.
|
|
130
|
+
*
|
|
131
|
+
* @param string $path Candidate theme-relative path.
|
|
132
|
+
* @return bool
|
|
133
|
+
*/
|
|
134
|
+
private function is_safe_path( string $path ): bool {
|
|
135
|
+
return '' !== $path
|
|
136
|
+
&& false === strpos( $path, "\0" )
|
|
137
|
+
&& 0 !== strpos( $path, '/' )
|
|
138
|
+
&& 1 !== preg_match( '#(^|[\\\\/])\.\.([\\\\/]|$)#', $path );
|
|
139
|
+
}
|
|
140
|
+
|
|
32
141
|
/**
|
|
33
142
|
* Tune the `sizes` attribute for content images.
|
|
34
143
|
*
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
test('layout renders header, content and footer', async ({ page }) => {
|
|
4
|
+
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
|
5
|
+
await expect(page.getByRole('banner')).toBeVisible()
|
|
6
|
+
await expect(page.locator('main').first()).toBeVisible()
|
|
7
|
+
await expect(page.getByRole('contentinfo')).toBeVisible()
|
|
8
|
+
// Patterns that fail to resolve render nothing, silently.
|
|
9
|
+
const text = (await page.locator('main').first().innerText()).trim()
|
|
10
|
+
expect(text.length).toBeGreaterThan(0)
|
|
11
|
+
})
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"build": "vite build",
|
|
11
11
|
"preview": "vite preview",
|
|
12
12
|
"ai:setup": "node scripts/ai-setup.mjs",
|
|
13
|
-
"ai:check": "pnpm build"
|
|
13
|
+
"ai:check": "pnpm build",
|
|
14
|
+
"lint:css": "stylelint \"src/**/*.{css,scss}\"",
|
|
15
|
+
"test:e2e": "playwright test --config playwright.smoke.config.ts"
|
|
14
16
|
},
|
|
15
17
|
"dependencies": {
|
|
16
18
|
"@wordpress/block-editor": "^12.19.0",
|
|
@@ -23,9 +25,13 @@
|
|
|
23
25
|
"react-dom": "^18.2.0"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
28
|
+
"@playwright/test": "^1.58.2",
|
|
29
|
+
"@stratawp/stylelint-config": "workspace:*",
|
|
30
|
+
"@stratawp/testing": "workspace:*",
|
|
26
31
|
"@stratawp/vite-plugin": "workspace:*",
|
|
27
32
|
"@vitejs/plugin-react": "^4.2.1",
|
|
28
33
|
"sass": "^1.94.2",
|
|
34
|
+
"stylelint": "^16.0.0",
|
|
29
35
|
"typescript": "^5.3.3",
|
|
30
36
|
"vite": "^5.0.10"
|
|
31
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@stratawp/stylelint-config'
|
|
@@ -29,6 +29,115 @@ class ImageSizes implements ComponentInterface {
|
|
|
29
29
|
add_filter( 'wp_get_attachment_image_attributes', array( $this, 'filter_post_thumbnail_sizes_attr' ), 10, 3 );
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Raster extensions the build pipeline produces AVIF/WebP siblings for.
|
|
34
|
+
*
|
|
35
|
+
* @var string[]
|
|
36
|
+
*/
|
|
37
|
+
private const RASTER_EXTENSIONS = array( 'jpg', 'jpeg', 'png' );
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sibling formats in preference order (best compression first).
|
|
41
|
+
*
|
|
42
|
+
* @var array<string, string>
|
|
43
|
+
*/
|
|
44
|
+
private const SIBLING_TYPES = array(
|
|
45
|
+
'avif' => 'image/avif',
|
|
46
|
+
'webp' => 'image/webp',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attribute names the caller may not set (the helper owns them).
|
|
51
|
+
*
|
|
52
|
+
* @var string[]
|
|
53
|
+
*/
|
|
54
|
+
private const RESERVED_ATTRIBUTES = array( 'src', 'srcset', 'alt', 'type' );
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find AVIF/WebP siblings of a theme image that exist on disk.
|
|
58
|
+
*
|
|
59
|
+
* @param string $relative_path Path relative to the theme directory (e.g. `dist/images/hero.jpg`).
|
|
60
|
+
* @return array<int, array{type: string, srcset: string}> Sources, AVIF first.
|
|
61
|
+
*/
|
|
62
|
+
public function get_picture_sources( string $relative_path ): array {
|
|
63
|
+
$extension = strtolower( pathinfo( $relative_path, PATHINFO_EXTENSION ) );
|
|
64
|
+
|
|
65
|
+
if ( ! $this->is_safe_path( $relative_path ) || ! in_array( $extension, self::RASTER_EXTENSIONS, true ) ) {
|
|
66
|
+
return array();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$base = substr( $relative_path, 0, -( strlen( $extension ) + 1 ) );
|
|
70
|
+
$sources = array();
|
|
71
|
+
|
|
72
|
+
foreach ( self::SIBLING_TYPES as $sibling_extension => $mime_type ) {
|
|
73
|
+
$sibling = $base . '.' . $sibling_extension;
|
|
74
|
+
|
|
75
|
+
if ( file_exists( get_theme_file_path( $sibling ) ) ) {
|
|
76
|
+
$sources[] = array(
|
|
77
|
+
'type' => $mime_type,
|
|
78
|
+
'srcset' => get_theme_file_uri( $sibling ),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return $sources;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render a theme image as `<picture>` with AVIF/WebP sources when available.
|
|
88
|
+
*
|
|
89
|
+
* @param string $relative_path Path relative to the theme directory.
|
|
90
|
+
* @param string $alt Alternative text.
|
|
91
|
+
* @param array<string,string> $attributes Extra `<img>` attributes (names must be lowercase, no `on*` handlers).
|
|
92
|
+
* @return string Markup, or an empty string when the path is unsafe or the original is missing.
|
|
93
|
+
*/
|
|
94
|
+
public function render_picture( string $relative_path, string $alt, array $attributes = array() ): string {
|
|
95
|
+
if ( ! $this->is_safe_path( $relative_path ) || ! file_exists( get_theme_file_path( $relative_path ) ) ) {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
$attribute_html = '';
|
|
100
|
+
foreach ( $attributes as $name => $value ) {
|
|
101
|
+
$name = (string) $name;
|
|
102
|
+
|
|
103
|
+
if ( 1 !== preg_match( '/^[a-z][a-z0-9-]*$/', $name )
|
|
104
|
+
|| 0 === strpos( $name, 'on' )
|
|
105
|
+
|| in_array( $name, self::RESERVED_ATTRIBUTES, true )
|
|
106
|
+
) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$attribute_html .= sprintf( ' %s="%s"', $name, esc_attr( (string) $value ) );
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$sources = '';
|
|
114
|
+
foreach ( $this->get_picture_sources( $relative_path ) as $source ) {
|
|
115
|
+
$sources .= sprintf( '<source type="%s" srcset="%s">', esc_attr( $source['type'] ), esc_url( $source['srcset'] ) );
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
$image = sprintf(
|
|
119
|
+
'<img src="%s" alt="%s"%s>',
|
|
120
|
+
esc_url( get_theme_file_uri( $relative_path ) ),
|
|
121
|
+
esc_attr( $alt ),
|
|
122
|
+
$attribute_html
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return '' === $sources ? $image : '<picture>' . $sources . $image . '</picture>';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reject empty, absolute, NUL-containing, or traversing paths.
|
|
130
|
+
*
|
|
131
|
+
* @param string $path Candidate theme-relative path.
|
|
132
|
+
* @return bool
|
|
133
|
+
*/
|
|
134
|
+
private function is_safe_path( string $path ): bool {
|
|
135
|
+
return '' !== $path
|
|
136
|
+
&& false === strpos( $path, "\0" )
|
|
137
|
+
&& 0 !== strpos( $path, '/' )
|
|
138
|
+
&& 1 !== preg_match( '#(^|[\\\\/])\.\.([\\\\/]|$)#', $path );
|
|
139
|
+
}
|
|
140
|
+
|
|
32
141
|
/**
|
|
33
142
|
* Tune the `sizes` attribute for content images.
|
|
34
143
|
*
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
test('layout renders header, content and footer', async ({ page }) => {
|
|
4
|
+
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
|
5
|
+
await expect(page.getByRole('banner')).toBeVisible()
|
|
6
|
+
await expect(page.locator('main').first()).toBeVisible()
|
|
7
|
+
await expect(page.getByRole('contentinfo')).toBeVisible()
|
|
8
|
+
// Patterns that fail to resolve render nothing, silently.
|
|
9
|
+
const text = (await page.locator('main').first().innerText()).trim()
|
|
10
|
+
expect(text.length).toBeGreaterThan(0)
|
|
11
|
+
})
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"build": "vite build",
|
|
11
11
|
"preview": "vite preview",
|
|
12
12
|
"ai:setup": "node scripts/ai-setup.mjs",
|
|
13
|
-
"ai:check": "pnpm build"
|
|
13
|
+
"ai:check": "pnpm build",
|
|
14
|
+
"lint:css": "stylelint \"src/**/*.{css,scss}\"",
|
|
15
|
+
"test:e2e": "playwright test --config playwright.smoke.config.ts"
|
|
14
16
|
},
|
|
15
17
|
"dependencies": {
|
|
16
18
|
"@wordpress/block-editor": "^12.19.0",
|
|
@@ -23,9 +25,13 @@
|
|
|
23
25
|
"react-dom": "^18.2.0"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
28
|
+
"@playwright/test": "^1.58.2",
|
|
29
|
+
"@stratawp/stylelint-config": "workspace:*",
|
|
30
|
+
"@stratawp/testing": "workspace:*",
|
|
26
31
|
"@stratawp/vite-plugin": "workspace:*",
|
|
27
32
|
"@vitejs/plugin-react": "^4.2.1",
|
|
28
33
|
"sass": "^1.94.2",
|
|
34
|
+
"stylelint": "^16.0.0",
|
|
29
35
|
"typescript": "^5.3.3",
|
|
30
36
|
"vite": "^5.0.10"
|
|
31
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@stratawp/stylelint-config'
|
|
@@ -29,6 +29,115 @@ class ImageSizes implements ComponentInterface {
|
|
|
29
29
|
add_filter( 'wp_get_attachment_image_attributes', array( $this, 'filter_post_thumbnail_sizes_attr' ), 10, 3 );
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Raster extensions the build pipeline produces AVIF/WebP siblings for.
|
|
34
|
+
*
|
|
35
|
+
* @var string[]
|
|
36
|
+
*/
|
|
37
|
+
private const RASTER_EXTENSIONS = array( 'jpg', 'jpeg', 'png' );
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sibling formats in preference order (best compression first).
|
|
41
|
+
*
|
|
42
|
+
* @var array<string, string>
|
|
43
|
+
*/
|
|
44
|
+
private const SIBLING_TYPES = array(
|
|
45
|
+
'avif' => 'image/avif',
|
|
46
|
+
'webp' => 'image/webp',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attribute names the caller may not set (the helper owns them).
|
|
51
|
+
*
|
|
52
|
+
* @var string[]
|
|
53
|
+
*/
|
|
54
|
+
private const RESERVED_ATTRIBUTES = array( 'src', 'srcset', 'alt', 'type' );
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find AVIF/WebP siblings of a theme image that exist on disk.
|
|
58
|
+
*
|
|
59
|
+
* @param string $relative_path Path relative to the theme directory (e.g. `dist/images/hero.jpg`).
|
|
60
|
+
* @return array<int, array{type: string, srcset: string}> Sources, AVIF first.
|
|
61
|
+
*/
|
|
62
|
+
public function get_picture_sources( string $relative_path ): array {
|
|
63
|
+
$extension = strtolower( pathinfo( $relative_path, PATHINFO_EXTENSION ) );
|
|
64
|
+
|
|
65
|
+
if ( ! $this->is_safe_path( $relative_path ) || ! in_array( $extension, self::RASTER_EXTENSIONS, true ) ) {
|
|
66
|
+
return array();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$base = substr( $relative_path, 0, -( strlen( $extension ) + 1 ) );
|
|
70
|
+
$sources = array();
|
|
71
|
+
|
|
72
|
+
foreach ( self::SIBLING_TYPES as $sibling_extension => $mime_type ) {
|
|
73
|
+
$sibling = $base . '.' . $sibling_extension;
|
|
74
|
+
|
|
75
|
+
if ( file_exists( get_theme_file_path( $sibling ) ) ) {
|
|
76
|
+
$sources[] = array(
|
|
77
|
+
'type' => $mime_type,
|
|
78
|
+
'srcset' => get_theme_file_uri( $sibling ),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return $sources;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render a theme image as `<picture>` with AVIF/WebP sources when available.
|
|
88
|
+
*
|
|
89
|
+
* @param string $relative_path Path relative to the theme directory.
|
|
90
|
+
* @param string $alt Alternative text.
|
|
91
|
+
* @param array<string,string> $attributes Extra `<img>` attributes (names must be lowercase, no `on*` handlers).
|
|
92
|
+
* @return string Markup, or an empty string when the path is unsafe or the original is missing.
|
|
93
|
+
*/
|
|
94
|
+
public function render_picture( string $relative_path, string $alt, array $attributes = array() ): string {
|
|
95
|
+
if ( ! $this->is_safe_path( $relative_path ) || ! file_exists( get_theme_file_path( $relative_path ) ) ) {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
$attribute_html = '';
|
|
100
|
+
foreach ( $attributes as $name => $value ) {
|
|
101
|
+
$name = (string) $name;
|
|
102
|
+
|
|
103
|
+
if ( 1 !== preg_match( '/^[a-z][a-z0-9-]*$/', $name )
|
|
104
|
+
|| 0 === strpos( $name, 'on' )
|
|
105
|
+
|| in_array( $name, self::RESERVED_ATTRIBUTES, true )
|
|
106
|
+
) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$attribute_html .= sprintf( ' %s="%s"', $name, esc_attr( (string) $value ) );
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$sources = '';
|
|
114
|
+
foreach ( $this->get_picture_sources( $relative_path ) as $source ) {
|
|
115
|
+
$sources .= sprintf( '<source type="%s" srcset="%s">', esc_attr( $source['type'] ), esc_url( $source['srcset'] ) );
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
$image = sprintf(
|
|
119
|
+
'<img src="%s" alt="%s"%s>',
|
|
120
|
+
esc_url( get_theme_file_uri( $relative_path ) ),
|
|
121
|
+
esc_attr( $alt ),
|
|
122
|
+
$attribute_html
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return '' === $sources ? $image : '<picture>' . $sources . $image . '</picture>';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reject empty, absolute, NUL-containing, or traversing paths.
|
|
130
|
+
*
|
|
131
|
+
* @param string $path Candidate theme-relative path.
|
|
132
|
+
* @return bool
|
|
133
|
+
*/
|
|
134
|
+
private function is_safe_path( string $path ): bool {
|
|
135
|
+
return '' !== $path
|
|
136
|
+
&& false === strpos( $path, "\0" )
|
|
137
|
+
&& 0 !== strpos( $path, '/' )
|
|
138
|
+
&& 1 !== preg_match( '#(^|[\\\\/])\.\.([\\\\/]|$)#', $path );
|
|
139
|
+
}
|
|
140
|
+
|
|
32
141
|
/**
|
|
33
142
|
* Tune the `sizes` attribute for content images.
|
|
34
143
|
*
|