Skip to content

Commit 8e07f06

Browse files
committed
Support SPDX IDs with dots in license URL slugs
1 parent 0bed4c5 commit 8e07f06

2 files changed

Lines changed: 192 additions & 5 deletions

File tree

mu-plugins/osi-api/osi-api.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public function register_routes() {
7979

8080
register_rest_route(
8181
OSI_API_NAMESPACE,
82-
'/license/(?P<slug>[a-zA-Z0-9-_]+)',
82+
'/license/(?P<slug>[a-zA-Z0-9._-]+)',
8383
array(
8484
'methods' => 'GET',
8585
'callback' => array( $this, 'get_license_by_slug' ),

plugins/osi-features/inc/classes/post-types/class-post-type-license.php

Lines changed: 191 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,202 @@ class Post_Type_License extends Base {
2626
*/
2727
const LABEL = 'License';
2828

29+
/**
30+
* Cache group for dotted slug lookups.
31+
*
32+
* @var string
33+
*/
34+
const CACHE_GROUP = 'osi_license_slugs';
35+
36+
/**
37+
* To register action/filters.
38+
*
39+
* @return void
40+
*/
41+
protected function setup_hooks() {
42+
parent::setup_hooks();
43+
44+
// Save side: preserve dots when creating/updating license posts.
45+
add_filter( 'wp_insert_post_data', array( $this, 'preserve_dots_on_save' ), 10, 2 );
46+
add_filter( 'wp_unique_post_slug', array( $this, 'preserve_dots_in_unique_slug' ), 10, 6 );
47+
48+
// Query side: restore dotted slug during lookups so WP_Query finds the post.
49+
add_filter( 'sanitize_title', array( $this, 'restore_dots_on_query' ), 10, 3 );
50+
51+
// Cache invalidation: clear when a license is saved, trashed, or deleted.
52+
add_action( 'save_post_' . self::SLUG, array( $this, 'clear_slug_cache' ) );
53+
add_action( 'before_delete_post', array( $this, 'clear_slug_cache' ) );
54+
add_action( 'wp_trash_post', array( $this, 'clear_slug_cache' ) );
55+
}
56+
57+
/**
58+
* Preserve dots in the post slug when saving a license post.
59+
*
60+
* WordPress sanitize_title strips dots by default. This rebuilds
61+
* the slug with dots preserved using a placeholder swap.
62+
*
63+
* @param array $data An array of slashed, sanitized post data.
64+
* @param array $postarr An array of sanitized post data (unslashed).
65+
*
66+
* @return array The modified post data.
67+
*/
68+
public function preserve_dots_on_save( array $data, array $postarr ) {
69+
if ( self::SLUG !== $data['post_type'] ) {
70+
return $data;
71+
}
72+
73+
$raw_source = ! empty( $postarr['post_name'] ) ? $postarr['post_name'] : $data['post_title'];
74+
75+
if ( false === strpos( $raw_source, '.' ) ) {
76+
return $data;
77+
}
78+
79+
$data['post_name'] = $this->sanitize_slug_with_dots( $raw_source );
80+
81+
return $data;
82+
}
83+
84+
/**
85+
* Preserve dots in the unique post slug for license posts.
86+
*
87+
* WordPress may strip dots when checking slug uniqueness.
88+
* This ensures the dot-containing slug survives.
89+
*
90+
* @param string $slug The post slug.
91+
* @param integer $post_id Post ID.
92+
* @param string $post_status The post status.
93+
* @param string $post_type Post type.
94+
* @param integer $post_parent Post parent ID.
95+
* @param string $original_slug The original slug.
96+
*
97+
* @return string The slug with dots preserved for license posts.
98+
*/
99+
public function preserve_dots_in_unique_slug( string $slug, int $post_id, string $post_status, string $post_type, int $post_parent, string $original_slug ) {
100+
if ( self::SLUG !== $post_type ) {
101+
return $slug;
102+
}
103+
104+
if ( false === strpos( $original_slug, '.' ) || false !== strpos( $slug, '.' ) ) {
105+
return $slug;
106+
}
107+
108+
// Only restore the dotted slug if no other license post already uses it.
109+
global $wpdb;
110+
$existing = $wpdb->get_var(
111+
$wpdb->prepare(
112+
"SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1",
113+
$original_slug,
114+
$post_type,
115+
$post_id
116+
)
117+
);
118+
119+
return $existing ? $slug : $original_slug;
120+
}
121+
122+
/**
123+
* Restore dotted slugs during query-time sanitization.
124+
*
125+
* When WordPress queries a post by slug, it runs sanitize_title
126+
* which strips dots. This checks whether a license post with the
127+
* dotted version of the slug exists and returns it if so.
128+
*
129+
* Uses wp_cache and a single $wpdb query for the existence check
130+
* to avoid infinite loops (get_posts would call sanitize_title again).
131+
*
132+
* @param string $title The sanitized title.
133+
* @param string $raw_title The raw title before sanitization.
134+
* @param string $context The context (e.g., 'save', 'display', 'query').
135+
*
136+
* @return string The dotted slug if a matching license exists, otherwise the default.
137+
*/
138+
public function restore_dots_on_query( string $title, string $raw_title, string $context ) {
139+
// Skip during saves — the wp_insert_post_data hook handles that.
140+
if ( 'save' === $context ) {
141+
return $title;
142+
}
143+
144+
if ( false === strpos( $raw_title, '.' ) ) {
145+
return $title;
146+
}
147+
148+
// Build what the dotted slug would look like.
149+
$dotted_slug = $this->sanitize_slug_with_dots( $raw_title );
150+
151+
// If sanitization didn't change anything, no dots were stripped.
152+
if ( $dotted_slug === $title ) {
153+
return $title;
154+
}
155+
156+
// Check cache first.
157+
$cache_key = 'slug_' . md5( $dotted_slug );
158+
$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
159+
160+
if ( false !== $cached ) {
161+
return $cached ? $dotted_slug : $title;
162+
}
163+
164+
// Check if a license post with this dotted slug exists.
165+
global $wpdb;
166+
$exists = (bool) $wpdb->get_var(
167+
$wpdb->prepare(
168+
"SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
169+
$dotted_slug,
170+
self::SLUG
171+
)
172+
);
173+
174+
wp_cache_set( $cache_key, $exists, self::CACHE_GROUP );
175+
176+
return $exists ? $dotted_slug : $title;
177+
}
178+
179+
/**
180+
* Clear the slug cache when a license post is saved, trashed, or deleted.
181+
*
182+
* @param integer $post_id The post ID.
183+
*
184+
* @return void
185+
*/
186+
public function clear_slug_cache( int $post_id ) {
187+
if ( self::SLUG !== get_post_type( $post_id ) ) {
188+
return;
189+
}
190+
191+
$post = get_post( $post_id );
192+
if ( ! $post ) {
193+
return;
194+
}
195+
196+
$cache_key = 'slug_' . md5( $post->post_name );
197+
wp_cache_delete( $cache_key, self::CACHE_GROUP );
198+
}
199+
200+
/**
201+
* Sanitize a string as a slug while preserving dots.
202+
*
203+
* Swaps dots for a placeholder, runs the standard WordPress
204+
* sanitize_title_with_dashes, then restores the dots.
205+
*
206+
* @param string $raw The raw string to sanitize.
207+
*
208+
* @return string The sanitized slug with dots preserved.
209+
*/
210+
private function sanitize_slug_with_dots( string $raw ) {
211+
$placeholder = 'xdotx';
212+
$with_placeholder = str_replace( '.', $placeholder, $raw );
213+
$sanitized = sanitize_title_with_dashes( $with_placeholder, '', 'save' );
214+
215+
return str_replace( $placeholder, '.', $sanitized );
216+
}
217+
29218
/**
30219
* To get list of labels for post type.
31220
*
32221
* @return array
33222
*/
34223
public function get_labels() {
35-
36-
return [
224+
return array(
37225
'name' => __( 'Licenses', 'osi-features' ),
38226
'singular_name' => __( 'License', 'osi-features' ),
39227
'all_items' => __( 'Licenses', 'osi-features' ),
@@ -45,7 +233,6 @@ public function get_labels() {
45233
'search_items' => __( 'Search License', 'osi-features' ),
46234
'not_found' => __( 'No License found', 'osi-features' ),
47235
'not_found_in_trash' => __( 'No License found in Trash', 'osi-features' ),
48-
];
49-
236+
);
50237
}
51238
}

0 commit comments

Comments
 (0)