@stratawp/cli 2.1.0 → 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.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/version.ts
4
+ import { readFileSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ import { dirname, join } from "path";
7
+ function getCliVersion() {
8
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
9
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
10
+ return packageJson.version;
11
+ }
12
+
13
+ export {
14
+ getCliVersion
15
+ };
package/dist/create.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getCliVersion
4
+ } from "./chunk-YCJH2PCK.js";
2
5
 
3
6
  // src/create.ts
4
7
  import prompts from "prompts";
@@ -360,9 +363,6 @@ async function detectWordPressSites() {
360
363
  return sites;
361
364
  }
362
365
  async function createBasicStructure(themePath, config) {
363
- const __filename2 = fileURLToPath2(import.meta.url);
364
- const __dirname2 = dirname2(__filename2);
365
- const cliPackageJson = await fs3.readJson(path3.join(__dirname2, "..", "package.json"));
366
366
  const packageJson = {
367
367
  name: config.slug,
368
368
  version: "1.0.0",
@@ -390,7 +390,7 @@ async function createBasicStructure(themePath, config) {
390
390
  typescript: config.typescript ? "^5.3.3" : void 0
391
391
  },
392
392
  stratawp: {
393
- createdWith: cliPackageJson.version,
393
+ createdWith: getCliVersion(),
394
394
  template: config.template,
395
395
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
396
396
  }
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getCliVersion
4
+ } from "./chunk-YCJH2PCK.js";
2
5
  import {
3
6
  generateBlock,
4
7
  generateComponent,
@@ -3756,7 +3759,7 @@ async function iconsListCommand() {
3756
3759
 
3757
3760
  // src/index.ts
3758
3761
  var program = new Command();
3759
- program.name("stratawp").description("\u26A1 A modern WordPress theme framework").version("2.0.0");
3762
+ program.name("stratawp").description("\u26A1 A modern WordPress theme framework").version(getCliVersion());
3760
3763
  program.command("dev").description("Start development server with hot reload").option("-p, --port <port>", "Port number", "3000").option("-h, --host <host>", "Host address", "localhost").action(devCommand);
3761
3764
  program.command("build").description("Build theme for production").option("--analyze", "Analyze bundle size").action(buildCommand);
3762
3765
  program.command("block:new <name>").description("Create a new Gutenberg block (dynamic, server-rendered via render.php)").option("--category <category>", "Block category", "common").option("--styleFramework <framework>", "Style framework (none|tailwind|unocss)", "none").action(blockCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratawp/cli",
3
- "version": "2.1.0",
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/explorer": "^0.7.1",
68
- "@stratawp/sync": "^0.1.1"
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.2.1"
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,3 @@
1
+ dist
2
+ vendor
3
+ node_modules
@@ -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,3 @@
1
+ import { createSmokeConfig } from '@stratawp/testing/config'
2
+
3
+ export default createSmokeConfig({ testDir: './e2e/smoke' })
@@ -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,3 @@
1
+ dist
2
+ vendor
3
+ node_modules
@@ -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,3 @@
1
+ import { createSmokeConfig } from '@stratawp/testing/config'
2
+
3
+ export default createSmokeConfig({ testDir: './e2e/smoke' })
@@ -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,3 @@
1
+ dist
2
+ vendor
3
+ node_modules
@@ -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,3 @@
1
+ import { createSmokeConfig } from '@stratawp/testing/config'
2
+
3
+ export default createSmokeConfig({ testDir: './e2e/smoke' })
@@ -255,10 +255,10 @@
255
255
 
256
256
  .cart {
257
257
  margin-bottom: 2rem;
258
+ }
258
259
 
259
- .quantity {
260
- margin-right: 1rem;
261
- }
260
+ .cart .quantity {
261
+ margin-right: 1rem;
262
262
  }
263
263
  }
264
264
  }
@@ -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
  *