@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratawp/cli",
3
- "version": "2.1.1",
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
  *