@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.
- package/dist/chunk-YCJH2PCK.js +15 -0
- package/dist/create.js +4 -4
- package/dist/index.js +4 -1
- 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
|
@@ -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:
|
|
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(
|
|
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.
|
|
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
|
*
|