Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/explanations/architecture/dashboard-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ widgets/hello-world/
├── widget.json static metadata (name, title, description, help, actions, keywords, category, presentation, textdomain)
├── widget.ts metadata module: default-exports icon, attributes, example
├── render.tsx render module: default-exports the React component
└── style.module.css optional, injected at runtime by the build
├── style.module.css optional, injected at runtime by the build
└── report.csv optional static asset linked from an action `href`
```

The split between `widget.json` and `widget.ts` is deliberate. `widget.json` is build-time input: plain JSON the pipeline can read without executing code, including the translatable strings (`title`, `description`, `help`, `keywords`) the server localizes through `textdomain`.

Unlike the other translatable strings, `help` is an object: `content` plus optional `links`, meant for compact surfaces such as tooltips.

`actions` is a list of the declarative actions a widget exposes: each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`, with the label translated. A host renders them as links and decides where; the dashboard surfaces them in a "More" menu. Because navigation and download are the browser's, the widget declares an intent and a target without knowing its surface.
`actions`: declarative links (`id`, `label`, `href`, optional `download` / `openInNewTab`). Hosts place them; the dashboard uses a "More" menu. A relative `href` that exists under `widgets/{name}/` becomes a plugin URL at registration; missing relative non-admin files are dropped. `data:` and `javascript:` hrefs are rejected. Prefer absolute URLs for assets that must work in the plugin zip (which does not ship `widgets/`).

`widget.ts` is the live half of the metadata: values that only exist in JavaScript, such as the icon element or the `attributes` field schema (including optional `relevance` hints) that hosts feed into `DataForm`.

Expand Down
115 changes: 101 additions & 14 deletions lib/experimental/dashboard-widgets/widget-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,73 @@ function gutenberg_sanitize_widget_help( $help ) {
}

/**
* Constrains a widget's actions to their allowed shape: each entry keeps
* `id`, `label`, and `href`; entries missing any of those are dropped.
* Optional `download` and `openInNewTab` are copied when present.
* Resolves a widget-local file href to a plugin URL.
*
* @param array|null $actions Actions from the build manifest.
* @return array|null Sanitized actions, or null when there are none.
* Leaves absolute, scheme-relative, root-relative, and single-segment admin
* `.php` hrefs unchanged. Returns '' for local path traversal and for
* relative hrefs that are not a file under `widgets/{dir}/` (so
* `esc_url_raw()` cannot invent `http://filename`). Query strings on local
* filenames are not stripped: `report.csv?v=2` will not resolve as a file.
*
* @param string $href Action href.
* @param string $dir_name Widget directory name.
* @return string Plugin URL, original href, or ''.
*/
function gutenberg_resolve_widget_action_href( $href, $dir_name ) {
if ( ! is_string( $href ) || '' === $href ) {
return '';
}

// Absolute, scheme-relative, or schemed — including URLs with `..` in the path.
if ( preg_match( '#^([a-z][a-z0-9+.-]*:)?//#i', $href ) || str_contains( $href, ':' ) ) {
return $href;
}

// Root-relative paths (e.g. /wp-admin/…, /report.csv).
if ( str_starts_with( $href, '/' ) ) {
return $href;
}

if ( str_contains( $href, '..' ) ) {
return '';
}

$path_only = preg_split( '/[?#]/', $href, 2 )[0];
if ( str_ends_with( strtolower( $path_only ), '.php' ) ) {
// Single-segment admin entry points stay as-is. Deeper relative
// paths would come out of `esc_url_raw()` as `http://` URLs, and
// PHP files never resolve as local widget assets.
return str_contains( $path_only, '/' ) ? '' : $href;
}

if ( ! is_string( $dir_name ) || '' === $dir_name ) {
return '';
}

$candidate = 'widgets/' . $dir_name . '/' . $href;

if ( is_file( gutenberg_dir_path() . $candidate ) ) {
return gutenberg_url( $candidate );
}

return '';
}

/**
* Sanitizes widget actions to `id` / `label` / `href` (via `esc_url_raw()`),
* plus optional `download` / `openInNewTab`. Drops incomplete or unsafe
* entries; dropped hrefs are reported through `_doing_it_wrong()`. With
* `$dir_name`, resolves widget-local file hrefs first.
*
* This is the registration gate for manifest-sourced widget types. Definitions
* registered only on the client do not pass through it; any future CPT/API
* source should reuse this helper at that boundary.
*
* @param array|null $actions Actions from the build manifest.
* @param string $dir_name Optional widget directory for local asset hrefs.
* @return array|null Sanitized actions, or null.
*/
function gutenberg_sanitize_widget_actions( $actions ) {
function gutenberg_sanitize_widget_actions( $actions, $dir_name = '' ) {
if ( ! is_array( $actions ) ) {
return null;
}
Expand All @@ -127,23 +186,48 @@ function gutenberg_sanitize_widget_actions( $actions ) {
foreach ( $actions as $action ) {
if (
! is_array( $action ) ||
empty( $action['id'] ) ||
empty( $action['label'] ) ||
empty( $action['href'] )
! isset( $action['id'], $action['label'], $action['href'] ) ||
! is_string( $action['id'] ) ||
! is_string( $action['label'] ) ||
! is_string( $action['href'] ) ||
'' === $action['id'] ||
'' === $action['label'] ||
'' === $action['href']
) {
continue;
}

$href = gutenberg_resolve_widget_action_href( $action['href'], $dir_name );
$href = esc_url_raw( $href );
if ( ! $href ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: 1: Widget action id. 2: Declared action href. */
__( 'Dropped widget action "%1$s": href "%2$s" is neither an allowed URL nor an existing widget file.', 'gutenberg' ),
$action['id'],
$action['href']
),
'23.7.0'
);
continue;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to emit a warning here when WP_DEBUG is enabled, perhaps via _doing_it_wrong()? Since unresolved actions are silently dropped, this could help developers diagnose a typo or a missing asset without changing the intended behavior. Fine if you prefer not.

}

$entry = array(
'id' => $action['id'],
'label' => $action['label'],
'href' => $action['href'],
'href' => $href,
);

if ( isset( $action['download'] ) ) {
$entry['download'] = is_bool( $action['download'] )
? $action['download']
: (string) $action['download'];
if ( is_bool( $action['download'] ) ) {
$entry['download'] = $action['download'];
} else {
$filename = sanitize_file_name( (string) $action['download'] );
if ( $filename ) {
$entry['download'] = $filename;
}
}
}

if ( isset( $action['openInNewTab'] ) ) {
Expand Down Expand Up @@ -189,7 +273,10 @@ function gutenberg_register_widget_types() {
'title' => $widget['title'] ?? null,
'description' => $widget['description'] ?? null,
'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ),
'actions' => gutenberg_sanitize_widget_actions( $widget['actions'] ?? null ),
'actions' => gutenberg_sanitize_widget_actions(
$widget['actions'] ?? null,
$widget['dir_name'] ?? ''
),
'keywords' => $widget['keywords'] ?? null,
)
);
Expand Down
1 change: 1 addition & 0 deletions packages/widget-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

### Documentation

- Prefer a static CSV asset over a `data:` URL in the goal-progress story ([#80510](https://github.com/WordPress/gutenberg/pull/80510)).
- Add/improve default story and documentation ([#80423](https://github.com/WordPress/gutenberg/pull/80423])).
- Document how attribute `relevance` maps to surfaces: the prominent
surface, the settings surface, and the measured collapse ([#80208](https://github.com/WordPress/gutenberg/pull/80208)).
Expand Down
2 changes: 2 additions & 0 deletions packages/widget-dashboard/src/stories/goal-progress.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
metric,target,current,percent
revenue,5000,3600,72
2 changes: 1 addition & 1 deletion packages/widget-dashboard/src/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ const GOAL_ACTIONS: WidgetAction[] = [
{
id: 'export-progress',
label: 'Export progress',
href: 'data:text/csv;charset=utf-8,metric,target,current,percent%0Arevenue,5000,3600,72',
href: new URL( './goal-progress.csv', import.meta.url ).href,
download: 'goal-progress.csv',
},
];
Expand Down
1 change: 1 addition & 0 deletions packages/widget-primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Documentation

- Prefer widget-local files over `data:` URLs for action downloads ([#80510](https://github.com/WordPress/gutenberg/pull/80510)).
- Add an Actions doc page and a `WithActions` story, and cover `actions`
in the widget anatomy doc ([#80363](https://github.com/WordPress/gutenberg/pull/80363)).
- Spell out the accepted field-type name syntax: lowercase kebab-case
Expand Down
3 changes: 2 additions & 1 deletion packages/widget-primitives/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ Its optional `relevance` hint (`'high' | 'low'`) marks attributes a host may pro

It's a declarative action a widget type exposes.
Each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`.
`data:` and `javascript:` hrefs are rejected at registration. Prefer a file next to the widget, an absolute URL, or `downloadBlob` for generated content.

The widget names the intent and a link target; the host renders it as an anchor and owns placement. Only the `link` target exists today, and navigation and downloads are handled by the browser.
The widget names the intent and a link target; the host renders it as an anchor and owns placement.

### Field types

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello World
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ const actionsWidgetType: WidgetType< DemoAttributes > = {
{
id: 'export-greeting',
label: 'Export greeting',
href: 'data:text/plain;charset=utf-8,Hello%20World',
href: new URL( './greeting.txt', import.meta.url ).href,
download: 'greeting.txt',
},
] satisfies WidgetAction[],
Expand Down
12 changes: 8 additions & 4 deletions packages/widget-primitives/src/stories/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ Today, the only fulfillment is a `link`: a target the host renders as an anchor.
}
```

`href` is a complete URL or path; external URLs and admin entry points load a full page. Two optional flags refine it:
`href`: absolute URL, admin `.php` entry point, root-relative path, or a file
next to the widget (resolved to a plugin URL on the server). `data:` and
`javascript:` hrefs are rejected at registration. Use `downloadBlob` for client-generated
files. Query strings on local filenames (e.g. `report.csv?v=2`) are not
resolved as widget files.

- `download`: turns the target into a file download; a string names the file.
- `openInNewTab`: opens the target in a new browser tab.
- `download`: download instead of navigate; a string sets the filename.
- `openInNewTab`: open in a new tab.

```ts
{
id: 'export',
label: __( 'Export CSV' ),
href: reportCsvUrl,
href: 'report.csv', // widgets/{name}/report.csv
download: 'report.csv',
}
```
Expand Down
3 changes: 1 addition & 2 deletions packages/widget-primitives/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ export interface WidgetAction {
label: string;

/**
* Destination the action points at. External URLs and admin PHP entry
* points load a full page.
* Destination: URL, admin path, or widget-local file.
*/
href: string;

Expand Down
Loading
Loading