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
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.zipThe 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_telegram … ks_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 |
|
x |
ks_team_x |
X (Twitter) | x |
facebook |
ks_team_facebook |
facebook |
|
linkedin |
ks_team_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, ← 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.