Skip to content
Prev Previous commit
Next Next commit
Knowledge: Consolidate built-in types around behavior
Renames two of the three built-in knowledge types per the #77230 proposal
so every type is defined by behavior rather than by relation:

- `content` -> `instruction`: loaded by default when applicable. The
  site-wide guidelines singleton managed by Settings > Guidelines now
  carries the `instruction` term; the /wp/v2/content-guidelines route is
  otherwise unchanged.
- `artifact` -> `note`: private freeform working text, and the fallback
  term assigned on save when no type is given.
- `memory` stays as is.

The one-time migration in lib/upgrade.php now also re-slugs existing
terms (content -> instruction, artifact -> note), replacing term names
only when they still match the previous default labels so customized
labels survive. The re-slug runs independently of the legacy taxonomy
flip so partially migrated rows are covered too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
  • Loading branch information
gziolo and claude committed Jun 25, 2026
commit c915050b296e5062083e3ad1e58ab078c7a8dd68
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
/**
* Content Guidelines REST API Controller.
*
* Specialized controller for the site-wide "content" guideline singleton.
* Specialized controller for the site-wide guidelines singleton.
* Exposes a flat `/wp/v2/content-guidelines` endpoint that always reads,
* creates, and updates a single post tagged with the `content` term in
* the `wp_knowledge_type` taxonomy. Other knowledge posts (artifacts) are
* served by the standard `/wp/v2/knowledge` collection.
* creates, and updates a single post tagged with the `instruction` term in
* the `wp_knowledge_type` taxonomy. Other knowledge posts (notes, memories)
* are served by the standard `/wp/v2/knowledge` collection.
*
* @package gutenberg
*/
Expand Down Expand Up @@ -53,7 +53,7 @@ public function __construct() {
* Resolves a post ID to a content-typed guideline post.
*
* Restricts /wp/v2/content-guidelines/{id} to posts tagged with the
* `content` term. Other knowledge types are addressable only via the
* `instruction` term. Other knowledge types are addressable only via the
* standard /wp/v2/knowledge collection.
*
* @param int $id Post ID.
Expand Down Expand Up @@ -246,7 +246,7 @@ public function get_guidelines( WP_REST_Request $request ) {
* Creates the content guidelines singleton.
*
* Enforces the singleton constraint — only one post tagged with the
* `content` term may exist.
* `instruction` term may exist.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure.
Expand All @@ -261,19 +261,19 @@ public function create_item( $request ) {
);
}

$content_term_id = self::get_or_create_term_id(
Gutenberg_Knowledge_Post_Type::TERM_CONTENT,
__( 'Content', 'gutenberg' )
$instruction_term_id = self::get_or_create_term_id(
Gutenberg_Knowledge_Post_Type::TERM_INSTRUCTION,
__( 'Instruction', 'gutenberg' )
);
if ( is_wp_error( $content_term_id ) ) {
return $content_term_id;
if ( is_wp_error( $instruction_term_id ) ) {
return $instruction_term_id;
}

$prepared = $this->prepare_item_for_database( $request );
$prepared->post_type = $this->post_type;
$prepared->post_title = __( 'Guidelines', 'gutenberg' );
$prepared->tax_input = array(
Gutenberg_Knowledge_Post_Type::TAXONOMY => array( $content_term_id ),
Gutenberg_Knowledge_Post_Type::TAXONOMY => array( $instruction_term_id ),
);

if ( ! isset( $prepared->post_status ) ) {
Expand Down Expand Up @@ -630,7 +630,7 @@ protected function get_guidelines_post( ?string $status_filter = null ): ?WP_Pos
array(
'taxonomy' => Gutenberg_Knowledge_Post_Type::TAXONOMY,
'field' => 'slug',
'terms' => Gutenberg_Knowledge_Post_Type::TERM_CONTENT,
'terms' => Gutenberg_Knowledge_Post_Type::TERM_INSTRUCTION,
),
),
)
Expand Down Expand Up @@ -796,7 +796,7 @@ public function get_item_schema() {
* Resolve the `wp_knowledge_type` term by slug, creating it if missing.
*
* Used by the create flow to attach the freshly-inserted content guideline
* to the `content` term on first use, before the term is otherwise needed.
* to the `instruction` term on first use, before the term is otherwise needed.
*
* @param string $slug Term slug.
* @param string $name Human-readable term name, used when creating.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public function register_routes() {
* Resolves a parent post ID to a content-typed guideline post.
*
* Restricts /wp/v2/content-guidelines/{parent}/revisions to parents tagged
* with the `content` term. Revisions of other knowledge types are
* with the `instruction` term. Revisions of other knowledge types are
* addressable only via the standard /wp/v2/knowledge collection.
*
* @param int $parent_post_id Supplied ID.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ class Gutenberg_Knowledge_Post_Type {
const TAXONOMY = 'wp_knowledge_type';

/**
* Taxonomy term slug used for site-wide content guidelines.
* Taxonomy term slug used for the site-wide guidelines singleton.
*
* Instructions are loaded by default when applicable; the site-wide
* guidelines post managed by the Settings → Guidelines page carries
* this term.
*
* @var string
*/
const TERM_CONTENT = 'content';
const TERM_INSTRUCTION = 'instruction';

/**
* The standard guideline category meta keys.
Expand Down Expand Up @@ -192,14 +196,15 @@ public static function register(): void {
}

/**
* Determines whether a knowledge post belongs to the content singleton.
* Determines whether a knowledge post belongs to the site-wide
* guidelines singleton.
*
* Used by the /wp/v2/content-guidelines route to reject non-content-typed
* posts addressed by ID — those belong to the standard /wp/v2/knowledge
* collection.
* Used by the /wp/v2/content-guidelines route to reject posts without
* the `instruction` term addressed by ID — those belong to the standard
* /wp/v2/knowledge collection.
*
* @param int $post_id Post ID.
* @return bool True if the post has the `content` term.
* @return bool True if the post has the `instruction` term.
*/
public static function is_content_guideline( $post_id ) {
$terms = get_the_terms( $post_id, self::TAXONOMY );
Expand All @@ -208,7 +213,7 @@ public static function is_content_guideline( $post_id ) {
}

foreach ( $terms as $term ) {
if ( self::TERM_CONTENT === $term->slug ) {
if ( self::TERM_INSTRUCTION === $term->slug ) {
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ protected function handle_status_param( $post_status, $post_type ) {
* partial PATCH preserves the existing status.
*
* `wp_knowledge_type` is optional on create. When omitted, the post
* falls back to the default knowledge taxonomy term `artifact`.
* falls back to the default knowledge taxonomy term `note`.
Comment thread
gziolo marked this conversation as resolved.
Outdated
*
* @param WP_REST_Request $request Request object.
* @return stdClass|WP_Error Prepared post object or error.
Expand Down
2 changes: 1 addition & 1 deletion lib/experimental/knowledge/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ static function () {
* Register content singleton routes beside the standard CPT routes.
* The singleton rule is scoped to /wp/v2/content-guidelines for UI handling.
* The standard /wp/v2/knowledge route keeps default post handling for every
* `wp_knowledge` post. If `content` becomes a data level singleton, add
* `wp_knowledge` post. If `instruction` becomes a data level singleton, add
* enforcement to the default CPT route too.
*/
add_action(
Expand Down
18 changes: 9 additions & 9 deletions lib/experimental/knowledge/knowledge.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ function wp_knowledge_types(): array {
return apply_filters(
'wp_knowledge_types',
array(
'artifact' => array(
'title' => __( 'Artifact', 'gutenberg' ),
'instruction' => array(
'title' => __( 'Instruction', 'gutenberg' ),
),
'content' => array(
'title' => __( 'Content', 'gutenberg' ),
),
'memory' => array(
'memory' => array(
'title' => __( 'Memory', 'gutenberg' ),
),
'note' => array(
'title' => __( 'Note', 'gutenberg' ),
),
)
);
}
Expand All @@ -61,7 +61,7 @@ function wp_knowledge_types(): array {
if ( ! function_exists( '_wp_knowledge_ensure_default_type_term' ) ) {
/**
* Hook callback for the `save_post_wp_knowledge` action that assigns the
* `artifact` fallback term when a knowledge post is saved without a type
* `note` fallback term when a knowledge post is saved without a type
* term.
*
* Uses `get_the_terms()` so the check is served by the object term cache.
Expand All @@ -83,9 +83,9 @@ function _wp_knowledge_ensure_default_type_term( int $post_id ): void {
// Resolve to an ID up front (creating the term on first use):
// wp_set_object_terms() interprets strings as names for hierarchical
// taxonomies, not slugs.
$term = term_exists( 'artifact', 'wp_knowledge_type' );
$term = term_exists( 'note', 'wp_knowledge_type' );
if ( ! $term ) {
$term = wp_insert_term( 'artifact', 'wp_knowledge_type' );
$term = wp_insert_term( 'note', 'wp_knowledge_type' );
if ( is_wp_error( $term ) ) {
return;
}
Expand Down
51 changes: 48 additions & 3 deletions lib/upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ function _gutenberg_migrate_enable_real_time_collaboration() {

/**
* Rename the experimental Guidelines storage to Knowledge: `wp_guideline`
* posts become `wp_knowledge`, and `wp_guideline_type` terms move to the
* `wp_knowledge_type` taxonomy.
* posts become `wp_knowledge`, `wp_guideline_type` terms move to the
* `wp_knowledge_type` taxonomy, and the built-in type terms are re-slugged
* (`content` becomes `instruction`, `artifact` becomes `note`).
*
* Runs regardless of whether the `gutenberg-guidelines` experiment is
* currently enabled so rows created while it was previously on are migrated
Expand Down Expand Up @@ -124,7 +125,51 @@ function _gutenberg_migrate_guidelines_to_knowledge() {
array( 'taxonomy' => 'wp_knowledge_type' ),
array( 'taxonomy' => 'wp_guideline_type' )
);
clean_term_cache( array_map( 'intval', $term_ids ), 'wp_knowledge_type' );
}

/*
* Re-slug the renamed built-in types. Term names are only replaced when
* they still match the previous default label (raw slug or its original
* English title), so user-customized labels survive.
*/
$type_renames = array(
'content' => array(
'slug' => 'instruction',
'old_labels' => array( 'content', 'Content' ),
Comment thread
gziolo marked this conversation as resolved.
Outdated
'new_label' => __( 'Instruction', 'gutenberg' ),
),
'artifact' => array(
'slug' => 'note',
'old_labels' => array( 'artifact', 'Artifact' ),
'new_label' => __( 'Note', 'gutenberg' ),
),
);

foreach ( $type_renames as $old_slug => $rename ) {
$term = $wpdb->get_row(
$wpdb->prepare(
"SELECT t.term_id, t.name FROM {$wpdb->terms} t
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
WHERE tt.taxonomy = %s AND t.slug = %s",
'wp_knowledge_type',
$old_slug
)
);
if ( ! $term ) {
continue;
}

$update = array( 'slug' => $rename['slug'] );
if ( in_array( $term->name, $rename['old_labels'], true ) ) {
$update['name'] = $rename['new_label'];
}

$wpdb->update( $wpdb->terms, $update, array( 'term_id' => (int) $term->term_id ) );
Comment thread
gziolo marked this conversation as resolved.
Outdated
$term_ids[] = (int) $term->term_id;
}

if ( $term_ids ) {
clean_term_cache( array_unique( array_map( 'intval', $term_ids ) ), 'wp_knowledge_type' );
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,89 +422,89 @@ public function test_delete_item() {
}

/**
* Helper: insert an artifact-typed guideline post directly, bypassing the singleton route.
* Helper: insert a note-typed knowledge post directly, bypassing the singleton route.
*
* @return int Post ID.
*/
private function create_artifact_post() {
$artifact_term_id = self::factory()->term->create(
private function create_note_post() {
$note_term_id = self::factory()->term->create(
array(
'taxonomy' => Gutenberg_Knowledge_Post_Type::TAXONOMY,
'name' => 'Artifact',
'slug' => 'artifact',
'name' => 'Note',
'slug' => 'note',
)
);

$post_id = self::factory()->post->create(
array(
'post_type' => Gutenberg_Knowledge_Post_Type::POST_TYPE,
'post_status' => 'draft',
'post_title' => 'Artifact',
'post_title' => 'Note',
)
);

wp_set_object_terms( $post_id, array( $artifact_term_id ), Gutenberg_Knowledge_Post_Type::TAXONOMY );
wp_set_object_terms( $post_id, array( $note_term_id ), Gutenberg_Knowledge_Post_Type::TAXONOMY );

return $post_id;
}

/**
* Test that GET /content-guidelines/{id} rejects non-content-typed posts.
* Test that GET /content-guidelines/{id} rejects non-instruction-typed posts.
*
* @covers ::get_post
*/
public function test_get_item_rejects_artifact_post() {
public function test_get_item_rejects_note_post() {
wp_set_current_user( self::$admin_id );
$artifact_id = $this->create_artifact_post();
$note_post_id = $this->create_note_post();

$request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $artifact_id );
$request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $note_post_id );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
}

/**
* Test that PATCH /content-guidelines/{id} rejects non-content-typed posts.
* Test that PATCH /content-guidelines/{id} rejects non-instruction-typed posts.
*
* @covers ::get_post
*/
public function test_update_item_rejects_artifact_post() {
public function test_update_item_rejects_note_post() {
wp_set_current_user( self::$admin_id );
$artifact_id = $this->create_artifact_post();
$note_post_id = $this->create_note_post();

$request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $artifact_id );
$request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $note_post_id );
$request->set_param( 'status', 'publish' );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
}

/**
* Test that DELETE /content-guidelines/{id} rejects non-content-typed posts.
* Test that DELETE /content-guidelines/{id} rejects non-instruction-typed posts.
*
* @covers ::get_post
*/
public function test_delete_item_rejects_artifact_post() {
public function test_delete_item_rejects_note_post() {
wp_set_current_user( self::$admin_id );
$artifact_id = $this->create_artifact_post();
$note_post_id = $this->create_note_post();

$request = new WP_REST_Request( 'DELETE', self::REST_BASE . '/' . $artifact_id );
$request = new WP_REST_Request( 'DELETE', self::REST_BASE . '/' . $note_post_id );
$request->set_param( 'force', true );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
}

/**
* Test that /content-guidelines/{id}/revisions rejects non-content-typed parents.
* Test that /content-guidelines/{id}/revisions rejects non-instruction-typed parents.
*
* @covers Gutenberg_Content_Guidelines_Revisions_Controller::get_parent
*/
public function test_revisions_reject_artifact_parent() {
public function test_revisions_reject_note_parent() {
wp_set_current_user( self::$admin_id );
$artifact_id = $this->create_artifact_post();
$note_post_id = $this->create_note_post();

$request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $artifact_id . '/revisions' );
$request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $note_post_id . '/revisions' );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 );
Expand Down
Loading