WP Manifestindependent plugin directory
manifest / content / wp-ks-team

KS Team

The people behind the site as a content type: the team member post type, a hierarchical department, drag ordering, contacts and profile links, and a list shortcode and block.

by Konstantin Sorokin · github.com/kostyasorokin/wp-ks-team · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/kostyasorokin/wp-ks-team/archive/refs/heads/main.zip

The people behind the site as a content type. Registers the team post type, the hierarchical team_department taxonomy and the flat ks_team_specialization one, and adds what a member card needs: a job title, a one-line bio, a phone and an e-mail, one address per social network, a repeater of details and a downloadable file — plus drag ordering for members and departments, a list shortcode and block, and Person JSON-LD.

Nothing here is written for one site. Slugs, entity names, listing size, order, menu icon and which field groups an editor even sees are settings; the post type arguments, the network list, the icons, the templates and the schema output are filters. The same plugin runs a law firm's attorneys and a clinic's doctors.

Contents: Requirements · Install · Settings · Fields · Personal pages · Constants · Filters · Shortcode and block · Theme API · Templates · Development

Requirements

PHP 8.5, WordPress 6.7.

Declared in the plugin header, so WordPress blocks installing and activating it below 8.5. The header is checked at those two moments only, so the bootstrap compares the running version itself before it loads anything: a server downgraded under an already-active plugin gets an admin notice and a plugin that stays out of the way, instead of a parse error on the 8.5-only syntax in src/. Composer's platform_check.php is not used for this — it prints its complaint and a 500 header before it throws, which no catch can take back — and is switched off with "platform-check": false.

Install

cd wp-content/plugins
git clone https://github.com/kostyasorokin/wp-ks-team.git ks-team

Then activate. vendor/ is committed — it holds Composer's autoloader and no packages — so a clone runs as it stands, and git pull is the update. Nothing has to be configured before it works; activation registers the post type and the taxonomy by hand (init does not fire during activation), numbers any departments and members that have no position yet, and flushes the rewrite rules.

A site that already has people under other names adopts the plugin through the constants below, without moving a row.

The settings screen

Team → Settings. Five sections, fifteen fields, all of them stored as plain options under their own names.

Section Option Default What it does
Addresses ksTeamMemberPages 1 whether members have public pages at all
Addresses ksTeamMemberSlug team-members the address of one member: /team-members/jane-doe/
Addresses ksTeamArchiveSlug team-members the page listing everyone; may be the same string as above
Addresses ksTeamDepartmentSlug team-department the address of one department archive
Addresses ksTeamSpecializationSlug specialization the address of one specialization archive
Naming ksTeamSingularName (empty) what one of them is called
Naming ksTeamPluralName (empty) what several are called; this is the admin menu label
Naming ksTeamDepartmentSingularName (empty) what one grouping is called
Naming ksTeamDepartmentPluralName (empty) what several groupings are called
Fields ksTeamFields contacts,socials,details,file,specializations which field groups the editing screen draws — see Field visibility
Archive ksTeamArchiveTitle (empty) the heading over the list of everyone
Archive ksTeamArchiveSubtitle (empty) one line under that heading
Archive ksTeamPerPage 12 how many members a listing shows before it paginates
Archive ksTeamFrontOrder 1 listings follow the drag order; off means newest first
Admin ksTeamMenuIcon dashicons-groups a dashicons class, or a data: URI

The naming defaults are empty on purpose: an empty option means "use the translated word", so a site that never opens this screen calls its people whatever the language it runs in calls them.

Two things worth knowing before touching the Addresses section. Changing any slug changes public URLs, and the rules are rewritten on the next request, not on save. The member slug is the one the plugin cleans up after itself: its old addresses are 301'd to the new ones, as Personal pages sets out. The archive, department and specialization slugs are not — what was indexed under those will 404.

And the two slugs that may overlap are the member and the archive slug, which is the pairing that ships: WordPress lists at /team-members/ and opens one member at /team-members/jane-doe/. Neither taxonomy slug may equal the member slug, and they may not equal each other. Whichever permastruct is registered first claims the address, so every archive behind it would resolve as something that does not exist and 404; the screen refuses such a value and says so.

Each of those three is checked against where the other two are going in the same submission, not against the addresses they are giving up, so exchanging two slugs — or rotating three — goes through in one save. Two fields sent the same address are a different thing and are both refused, each keeping what it had.

A slug that does not clean to anything usable is refused and the previous value kept, with a settings error on the screen.

One option in the group has no field, because nobody chooses it: ksTeamPreviousMemberSlugs is the plugin's own record of the member slugs this site has published under — see Personal pages.

Field visibility

The Fields section is a list of checkboxes, one per group, deciding what the member editing screen draws:

Group Stored as Hides
Contacts contacts the phone and the e-mail
Profiles socials every social network input
Details details the label/value repeater
File file the media-library picker
Specializations specializations the specializations box

All five ship ticked, and the option holds them as one comma-separated string.

Unticking a group hides its inputs and does nothing else. Nothing stored is deleted, nothing stored is ignored: the front-end helpers keep returning whatever is in the database, the templates keep printing it, and ticking the group back on brings the filled-in values back to the screen exactly as they were. It is a question about the editing screen, not about the data — which is why a site can hide the file picker for a year and lose nothing by changing its mind.

An empty list is a real answer and is stored as one: a site may hide every group. A value in the row that names no group the plugin has is ignored rather than trusted, so a hand-edited option cannot conjure a sixth group.

Personal pages

Whether a member has a page of their own, and whether their name is a link to it, are two separate questions with two separate answers.

The site-wide answer is a setting. ksTeamMemberPages, on by default, under Addresses. Off, members have no public face at all: the post type registers with public, publicly_queryable and has_archive off, no rewrite rules and show_in_rest off, so single addresses, the archive and the REST route all stop answering — which also puts editing back on the classic screen. exclude_from_search is deliberately left off: the switch removes public pages, not the type itself, so the department and specialization archives keep listing members. What it does not touch is the admin — show_ui and show_in_menu are spelled out rather than derived, so the list table, the editing screen, the boxes and the departments stay exactly as they were. It is a URL-affecting setting, so saving it rewrites the rules on the next request like any slug change.

The per-member answer is a checkbox on the editing screen, stored as team_page_link — see the constants table. Off by default: an absent row means no link, which is the state every member imported or created before this existed is in.

It governs one thing: whether the member's name is printed as a link anywhere the plugin renders it. Unticked, the card prints the name as a <span> carrying the same class the <a> did, so a theme's card styles hold either way.

It does not hide the page. With the box unticked the member's page stays publicly reachable at its own address, stays in search results and stays indexable — nothing 404s, noindexes or redirects because of it. That is a deliberate choice, not an oversight: the checkbox is about how the team is presented, not about taking a URL away.

Both questions meet in one place, and everything that renders a name asks there rather than reading the setting and the meta again:

Method Answers
Helpers\Team::hasPage( int $postId ) true when the setting is on and this member's box is ticked
Helpers\Team::permalink( int $postId ) the permalink when hasPage(), otherwise ''

A theme prints a plain name whenever permalink() comes back empty. Do not fall back to get_permalink() there — that is the fallback the rule exists to prevent.

Old addresses keep working

Renaming the member slug moves every member's URL, and what is already in a sitemap, a search index or somebody's link does not move with it. So the plugin remembers: when ksTeamMemberSlug changes, the slug being left behind is pushed onto ksTeamPreviousMemberSlugs — a comma-separated list, newest first, deduplicated and capped at five, because a slug history is not a log.

On a 404, and only on a 404, a request whose path is exactly one of those old addresses — the remembered slug, then one more segment naming a published member — is answered with a 301 to that member's current permalink. Anything else is left as the 404 it was: no fuzzy matching, no guessing at near misses. A member whose personal page is unlinked still redirects, because the checkbox governs links and not addresses.

Two limits worth knowing before relying on it. Redirects fire only for slug changes the plugin itself saw happen: a slug edited straight in the options table, or one that was in use before this version, was never recorded and its addresses are not answered — write the row by hand if you need them to be. And nothing is recorded or answered while ksTeamMemberPages is off, since there is no page at the far end to send anyone to.

Fields

Every key the plugin owns, and what is on the other side of it. All of them are post meta on a member, all are single, and none is exposed over REST — the post type does not support custom-fields, so there is no meta property to expose them in.

Two prefixes, and why

Meta key Holds Sanitised with
team_role the job title printed under the name sanitize_text_field
team_phone the phone as typed sanitize_text_field
team_email the address, refused rather than repaired when malformed sanitize_email
team_bio the card's one line, which is not the editor's biography sanitize_text_field
team_page_link 1 when the name links to the member's page, absent when it does not MetaRegistrar::sanitizeFlag()
ks_team_telegramks_team_website one address per social network, one key each esc_url_raw
ks_team_details [{label, value}, …] — licence number, languages, years of practice MetaRegistrar::sanitizeDetails()
ks_team_file an attachment id: a CV, a vCard absint

The two spellings are deliberate and are not going to be unified. The team_* five predate this plugin: they were written by a theme, the rows already exist on the sites that adopted it, and renaming them would orphan every one. Everything added since carries the plugin's own ks_team_ prefix, which is what a new key would be called if the whole set were written today. A tidying pass that "fixed" the inconsistency would be a silent data migration, so the inconsistency stays.

teamOrder is the odd one out in a different way: it is term meta, holding a department's dragged position. A member's own position is menu_order on the post, which is core's column — see the note under the constants.

Social profiles

One key per network rather than a repeater of arbitrary links, because a fixed key is what lets a template put Telegram in the same place on every member and draw the right mark beside it.

The list of networks is not hard-coded at the call sites. It comes from Meta\Social::networks(), and the seven that ship are:

Slug Meta key Label Sprite icon
telegram ks_team_telegram Telegram telegram
instagram ks_team_instagram Instagram instagram
x ks_team_x X (Twitter) x
facebook ks_team_facebook Facebook facebook
linkedin ks_team_linkedin LinkedIn linkedin
youtube ks_team_youtube YouTube youtube
website ks_team_website Website (translated) website

The registrar declares one key per entry, the metabox draws one input per entry and the templates walk the same order, so one filter line adds a network everywhere at once.

A slug becomes a meta key and a form field name, so an entry the filter returns under an unusable slug — a space, an upper-case letter, a dot — is dropped whole rather than repaired. A repaired slug would file the address under a key nothing reads back. A missing label or icon falls back to the slug: a poor label, never an empty one.

There is no constant door on these keys, unlike the team_* five. The key is the prefix and the slug, and the prefix is a class constant. A site whose people already carry one URL per key under other names moves those rows with a migration.

Details

ks_team_details is the answer to "licence number / languages / years of practice / education" without shipping five narrow fields nobody else needs. Rows are ['label' => string, 'value' => string], stored as a list and read back by position; both halves are plain text and both go through sanitize_text_field(). This is "Licence number — ХА 001234", not a link and not markup.

A row blank on both sides is dropped on save — nobody filled it in. A row with only one half is kept, because it is a line an editor came back to finish and dropping it would delete their work. Helpers\Team::details() is stricter on the way out and skips a row with no value, since a label alone renders as a dangling term.

File

ks_team_file is one attachment id, chosen through the media library. Read it with Team::fileId() for the id or Team::fileUrl() for the address; the latter answers '' both for a member with no file and for one whose attachment has since been deleted, so a template never prints a dead link. Anything in the media library is public — that is WordPress, not this plugin, and the field's description says so.

An editor who may not read the stored attachment — one uploaded by somebody else onto a member still in draft — is not told its name or given its link, but the id is still in the field and still posted back, so their save keeps it. Only an emptied picker clears the row; an id that is not a readable attachment is left as it was rather than written or deleted.

Specializations

A second taxonomy, ks_team_specialization, flat where departments are hierarchical: a department is where somebody sits and nests, a specialization is a tag on what they do and a member usually carries several. It has its own URL slug setting (ksTeamSpecializationSlug, default specialization) and its own ksTeamSpecializationArgs filter.

The taxonomy name is a literal, not a constant a site may redefine. Departments carried adopted content and had to stay renameable; this one is new everywhere, so there is nothing to adopt.

No template ships for it. A specialization archive is served by whatever the theme answers taxonomy.php with — the plugin steps in only for the three pages listed under Templates.

Configuration in wp-config.php

Everything worth changing day to day is on the settings screen. What is not a setting is the set of strings every stored row is filed against — the post type, the taxonomy and the meta keys. Editing one of those on a site that already has content leaves the existing rows filed under a name nobody registers: the members vanish from the admin and their permalinks fall back to ?p=123.

They are still changeable, from wp-config.php or an mu-plugin, before the first member exists and never after:

Constant Default Holds
KS_TEAM_POST_TYPE team the post type name
KS_TEAM_TAXONOMY team_department the taxonomy name
KS_TEAM_META_ROLE team_role post meta — the job title under the name
KS_TEAM_META_PHONE team_phone post meta — the phone as typed
KS_TEAM_META_EMAIL team_email post meta — the address, validated on save
KS_TEAM_META_DETAILS ks_team_details post meta — [{label, value}, …]
KS_TEAM_META_FILE ks_team_file post meta — an attachment id
KS_TEAM_META_BIO team_bio post meta — the card's one line
KS_TEAM_META_PAGE_LINK team_page_link post meta — 1 when the name links to the member's page, absent when it does not
KS_TEAM_META_ORDER teamOrder term meta — the dragged position of a department

"Before the first member exists" is the whole warning. A constant defined after content is saved does not move anything: the rows keep the old key, the plugin reads the new one, and the site looks as though the data was deleted. If that has already happened, put the constant back to the value the rows use — nothing was lost.

The meta constants exist for one reason worth stating: they are how a site whose people already live under other keys adopts the plugin without migrating a row. Point the constants at what is already in the database and the plugin reads it.

define( 'KS_TEAM_POST_TYPE', 'kp_person' );
define( 'KS_TEAM_META_ROLE', 'kp_position' );
define( 'KS_TEAM_META_ORDER', 'kp_order' );

Two things the table does not cover. The social keys have no constant: each is the ks_team_ prefix and the network's slug, composed in Meta\Social::key(), so a site whose people already carry myTelegram and its siblings moves those rows with a migration rather than a define. And a constant renames a key; it cannot change a value's shape. ks_team_details is a list of {label, value} rows — pointing the constant at a key holding one flat string gives the plugin nothing it can read.

An enum's backing value is a compile-time literal and cannot be made to depend on configuration, which is why the door is a constant and the lookup lives in MetaKey::key() — read that, never ->value.

Note that the member's own position is not in the table above: dragging a member writes menu_order on the post itself, which is core's column and needs no key of its own. Only the departments carry a position in meta.

Five more constants are defined by the bootstrap and are not settings: KS_TEAM_VERSION, KS_TEAM_MIN_PHP, KS_TEAM_PATH, KS_TEAM_URL and KS_TEAM_FILE. A theme may read them; overriding them is not supported.

The minimum PHP version is in the plugin header's Requires PHP, which is what WordPress enforces at install and activation, and in KS_TEAM_MIN_PHP, which is what the bootstrap compares the running version against. The Composer platform pin in composer.json is the third. Raise one, raise all three.

Filters

Thirteen of them, all prefixed ksTeam and all named in camelCase, which is this plugin's house style rather than WordPress's. Nothing here is a hook a theme has to answer; each one exists because a real site needed the seam.

Filter Fired in Receives Changes
ksTeamPostTypeArgs PostType::register() array $args everything passed to register_post_type() — capabilities, supports, menu_position, REST
ksTeamTaxonomyArgs Taxonomy::register() array $args everything passed to register_taxonomy() for departments. Read again by the term meta auth callback, so remapped caps hold there too
ksTeamSpecializationArgs Specialization::register() array $args the same, for the flat specializations taxonomy
ksTeamSocialNetworks Social::networks() array $networks the whole network list, keyed by slug — see Adding a network. Every entry is hardened afterwards, so a careless one costs that entry and not the list
ksTeamSocialSprites SocialIcons::sprites() string[] $paths the sprite files read, in resolution order: a later file wins, so a site redraws a shipped mark by declaring a symbol under the same id. Unreadable paths are skipped
ksTeamSocialIconHtml SocialIcons::render() string $html, string $icon, string $class the finished <svg> for one mark, after the fallback has been tried
ksTeamSocialIconSize SocialIcons::render() int $size (20) the px width and height written into every mark the caller did not size. A value below 1 falls back
ksTeamSocialFallbackIcon SocialIcons::render() string $icon (website) the mark drawn for an icon id no sprite carries. Nothing is drawn when this one is unknown too
ksTeamPerPage Settings::$perPage int $stored the listing page size. Clamped to at least 1 afterwards, so returning 0 does not mean "no limit"
ksTeamPostOrderEnabled PostOrder::enabled() bool $enabled (true) whether the plugin makes the members list table draggable. The AJAX endpoint refuses too, not just the script
ksTeamFallbackTemplate TemplateLoader::resolve() string $path, string $themeFile the absolute path of the template the plugin is about to serve. An unreadable path means "leave core's answer alone"
ksTeamSchemaEnabled Schema::print() bool $enabled, int $postId whether any JSON-LD is printed for this member at all
ksTeamSchemaData Schema::print() array $data, int $postId the finished Person graph before it is encoded. Return [] to print nothing

Adding a network

The one that earns its place. A site adds TikTok with a single filter and gets a ks_team_tiktok meta key, an input on the editing screen and an icon slot in the templates — without a line changing in the plugin:

add_filter(
    'ksTeamSocialNetworks',
    static function ( array $networks ): array {
        $networks['tiktok'] = [
            'label'       => 'TikTok',
            'placeholder' => 'https://tiktok.com/@username',
            'icon'        => 'tiktok',
        ];

        return $networks;
    }
);

Three keys per entry, and the array key is the slug. label heads the input and labels the link; placeholder is the example shown in the empty field and may be omitted; icon is the symbol id looked up in the sprites — omit it and the slug is used, which is usually what a site's own sprite named it anyway.

A slug must be usable as a meta key and as a form field name — [a-z0-9_-] — and its key, ks_team_<slug>, must not be one the plugin already owns. That rules out details and file, whose keys are ks_team_details and ks_team_file: a second register_post_meta() on a key chains its sanitiser onto the first rather than replacing it, so both fields would lose their values. Either slug is dropped from the list, and the entry never reaches a meta key, an input or a template.

The shipped sprite has no tiktok symbol, so add one. Append rather than replace, or the seven that ship disappear along with any address already stored under them:

add_filter(
    'ksTeamSocialSprites',
    static function ( array $paths ): array {
        $paths[] = get_stylesheet_directory() . '/icons/social.svg';

        return $paths;
    }
);

A sprite is a plain <svg> of <symbol id="…"> elements; a <title> inside one is kept out of the drawing, since the marks render aria-hidden and the link around them already names the network. A symbol under an id the plugin ships replaces that drawing, which is how a site restyles the shipped set without forking it.

Order matters and is the list's, not the storage's: entries come back in the order the filter left them in, and that is the order the inputs are drawn and the marks printed. array_merge() at the front puts TikTok before Telegram everywhere.

The three worth a snippet

Turning off the structured data an SEO plugin already prints. Two Person graphs on one URL are worse than none:

add_filter( 'ksTeamSchemaEnabled', '__return_false' );

Adding a property the plugin does not know about, without rebuilding the graph:

add_filter(
    'ksTeamSchemaData',
    static function ( array $data, int $postId ): array {
        $data['knowsLanguage'] = [ 'uk', 'en' ];

        return $data;
    },
    10,
    2
);

Handing the post type to an editor role, or taking editor support away because the bio is the whole page:

add_filter(
    'ksTeamPostTypeArgs',
    static function ( array $args ): array {
        $args['menu_position'] = 5;
        $args['supports']      = array_diff( $args['supports'], [ 'editor' ] );

        return $args;
    }
);

Leave page-attributes and revisions in supports unless you mean it: page-attributes is what persists menu_order, which is what dragging writes, and revisions is what makes wp_save_post_revision() run at all — without it the job title, the bio, the details, the file and the social addresses are not kept with a revision and a restore cannot bring them back.

The shortcode and the block

[ks_team] and the Team block (ks-team/team) are two doors into one renderer, so an attribute means the same thing in both. Both draw the same card partials the archive draws — restyle a card once and both follow.

Attribute Type Default Meaning
department string (empty) a department slug; empty lists everyone. Ignored when show="departments"
limit number 0 how many, 0 for all of them
columns number 3 grid columns, clamped to 1–4 — past four a card has no room for a name
show string members members lists the people; departments lists the departments instead
[ks_team limit="6" columns="3"]
[ks_team department="litigation" limit="4"]
[ks_team show="departments" columns="4"]

The block carries the same four attributes in blocks/team/block.json and supports wide and full alignment; its sidebar caps limit at 48, which the shortcode does not. Rendered when the page is served, so it always lists who is published now.

Two behaviours worth expecting. A list with nothing in it returns an empty string rather than an empty grid — a page that asked for people it has none of should read as though it never asked. And show="departments" uses the same canonical, drag-ordered department list the theme API gives out, with empty departments left out, because a department with nobody in it leads to a dead end.

The plugin's front stylesheet (handle ks-team-front) is enqueued only on pages that actually render a list.

The theme API

One class, every method static: KonstantinSorokin\Team\Helpers\Team. Guard the call — deactivating a plugin is an ordinary thing to do, and without the guard doing it fatals every page that calls in, including wp-admin, which is where the plugin would have been switched back on.

use KonstantinSorokin\Team\Helpers\Team;

if ( class_exists( Team::class ) ) {
    foreach ( Team::members( null, 4 ) as $member ) {
        $card = Team::card( $member->ID );

        printf(
            '<a class="tile" href="%s"><img src="%s" alt=""><strong>%s</strong><span>%s</span></a>',
            esc_url( $card['url'] ),
            esc_url( $card['photo'] ),
            esc_html( $card['title'] ),
            esc_html( $card['role'] )
        );
    }
}
Method Returns Notes
settings() Settings every setting as a typed property; a live view of the options table, not a snapshot
setting( string $name ) string one option by name, or its default
departments( bool $hideEmpty = true ) WP_Term[] the canonical list, in the dragged order. Sorted in PHP: a department with no stored position sorts last instead of vanishing from the result
members( ?int $departmentId = null, int $limit = 0 ) WP_Post[] in the dragged order, title breaking the tie
groupedByDepartment() array<int, WP_Post[]> everyone under every department they are filed in, keyed by term id, in one query. A member in no department is left out
primaryDepartment( int $postId ) ?WP_Term the one department to name when only one fits — highest-ranked, term id breaking the tie. The rule lives here alone, so the same person cannot be a litigator in one place and a notary in another
siblings( int $postId, int $limit = 0 ) WP_Post[] the rest of that department, in listing order, without this member. Empty for a member filed under none
role( int $postId ) string the job title shown under the name
phone( int $postId ) string as typed. A tel: href needs the digits alone — see templates/single-member.php
email( int $postId ) string as stored; sanitize_email() already refused a malformed one on save
bio( int $postId ) string the card's one line, which is not the editor's biography
socials( int $postId ) array{network, label, icon, url}[] only the networks this member filled in, in the list's order. icon arrives as a rendered <svg>, not an id — see below
details( int $postId ) array{label: string, value: string}[] normalised on the way out, so rows written by an import still arrive in the documented shape. A row with no value is skipped: a label alone renders as a dangling term
fileId( int $postId ) int the chosen attachment, 0 for a member with none
fileUrl( int $postId ) string that file's address; '' both for no file and for an attachment that has since been deleted
specializations( int $postId ) WP_Term[] this member's specializations, in name order — the taxonomy carries no position of its own
hasPage( int $postId ) bool whether this member's name should be a link: ksTeamMemberPages is on and their checkbox is ticked. Says nothing about whether the page exists — see Personal pages
permalink( int $postId ) string the member's address when hasPage(), and '' when the name is to be printed as plain text
photoUrl( int $postId, string $size = 'medium' ) string the featured image, '' for a member with none
card( int $postId ) array{title, url, role, bio, excerpt, photo} everything a card needs, for a theme drawing its own markup. url is permalink(), so it is '' for a member whose name is not a link
archiveUrl() string '' when the post type was registered without an archive

Team::departments() and Team::primaryDepartment() are the two to reach for rather than sorting terms again: both read the position through MetaKey::Order, which answers PHP_INT_MAX for a department that has none. A bare (int) cast turns '' into 0, which puts the same department first in one listing and last in another.

Team::hasPage() and Team::permalink() are the same kind of pair, for the same reason. One rule written out in three templates is three rules by the second time anyone edits it, so a name is linked where permalink() answers with an address and printed plain where it answers with '' — never by reading the setting and the meta a second time, and never with a get_permalink() fallback underneath.

Team::socials() hands back the mark already drawn, so a template never has to know where it came from. It is markup and needs wp_kses() rather than esc_html(), with the list the icons class publishes:

use KonstantinSorokin\Team\Helpers\Team;
use KonstantinSorokin\Team\Icons\SocialIcons;

foreach ( Team::socials( $postId ) as $profile ) {
    printf(
        '<a class="ks-team-social" href="%s" rel="me noopener" target="_blank"><span class="screen-reader-text">%s</span>%s</a>',
        esc_url( $profile['url'] ),
        esc_html( $profile['label'] ),
        wp_kses( $profile['icon'], SocialIcons::allowedTags() )
    );
}

None of these five reads the field-visibility settings, and that is deliberate: those govern which inputs an editor is shown, not what a stored value does on the front end. A theme that wants to hide something from a page hides it in the template.

Templates

Plain defaults ship for three pages, so a fresh site renders as a team rather than a blog roll:

Page Plugin ships A theme wins with
the archive templates/archive-member.php archive-team.php
one member templates/single-member.php single-team.php
a department templates/taxonomy-department.php taxonomy-team_department.php

A theme takes any of them over by simply having the file — no registration, no filter, no add_theme_support(). The theme-side names follow the actual post type and taxonomy, so a site that renamed them by constant overrides the file WordPress itself would have looked for.

Two answers the plugin never overrides. Anything more specific than archive.php / single.php / singular.php / taxonomy.php / index.php — a theme's single-team-jane-doe.php, a template chosen in Page Attributes — is somebody's explicit answer for that page and is left alone. And a block theme's, which is wp-includes/template-canvas.php: substituting a classic template there leaves get_header() with no header.php to find, and the page is served as a fragment with no <head> at all.

Listing parts live one folder down so they do not clutter the theme root:

Part A theme overrides it at
templates/parts/member-card.php ks-team/member-card.php
templates/parts/department-card.php ks-team/department-card.php

Each part takes one id and reads nothing else — $args['post_id'] for the member card, $args['term_id'] for the department card. Deliberately, because the shortcode renders cards from a plain query result with no setup_postdata(), and a card that relied on the current post would come out blank there. Render one from a theme with TemplateLoader::partial( 'member-card.php', [ 'post_id' => $id ] ).

ksTeamFallbackTemplate is the seam for a plugin that wants to serve a fourth file from somewhere else entirely; a theme has no reason to reach for it.

Development

The plugin's own composer.json requires PHP and nothing else. The tooling lives in tools/composer.json and installs into tools/vendor, which is ignored, so PHPUnit and PHPStan can never reach the autoloader that ships.

composer tools     # install PHPStan, PHPUnit, PHPCS into tools/vendor
composer check     # coding standards, static analysis, tests
composer dump      # rebuild the classmap after adding a class

composer dump matters more than usual here: vendor/ is committed, so a class added without re-dumping ships an autoloader that cannot find it. CI checks for exactly that, by dumping again and failing on a diff.

The repository is the development tree, tooling and all; the distributable is built from it with wp dist-archive ., which leaves out every path listed in .distignore.

The tests are unit tests and nothing else: no WordPress, no database. What core functions the covered classes touch are stubbed in tests/bootstrap.php, which is why the suite runs in well under a second and why it covers the pure logic — Slug, MetaKey, Field, FieldGroup, Settings, Social, MetaRegistrar::sanitizeDetails() and the two rules SlugRedirect is built around, the slug history and the request path — rather than the registrars around it.

That is also why those two are public static methods taking their input as arguments rather than reading an option and $_SERVER where they are used: a redirect rule nobody can test is a redirect rule nobody can change.

Translation

Source strings are English; the plugin loads its own catalogues from languages/ on init, because WordPress finds translations by itself only for plugins it installed from wordpress.org.

npm run i18n        # all three steps below, in order
npm run i18n:pot    # rescan the source into languages/ks-team.pot
npm run i18n:mo     # compile uk and ru_RU with msgfmt --check --strict
npm run i18n:json   # the block editor's strings, which core reads as JSON

i18n:json is not optional once blocks/team/editor.js changes a string: the editor script reads a JSON file named after the md5 of its own relative path, and a .mo alone leaves the sidebar in English.

Two decisions the catalogues make, worth keeping if you add a language. The entity names — "Team member", "Team", "Department", "Departments" — are translated as words a site would actually see in its admin menu, because an empty naming setting means "use the translated word". And every composed label is framed so it stays grammatical around a name the site typed in itself: All %s, Search %s, No %s found, Parent %s, New %s name, &larr; Go to %s. A frame that inflects the name — the natural phrasing in Ukrainian and Russian alike — reads as broken the moment someone renames the entity to "Attorneys" or "Лікарі", which is exactly what the naming settings are for.

Hooks are declared where they are answered

#[Hook( 'init', 20 )]
public function register(): void { … }

HookBinder reads those attributes off the objects listed in Plugin::boot() and calls add_filter(). One file says what the plugin consists of, and a renamed method cannot leave a registration pointing at nothing.

Licence

GPL-3.0-or-later.