People Developer’s Guide

Technical Documentation for People System

Audience: developers/agencies who will maintain, extend, or debug the People system on this site. This is not the client-facing guide (see the “People” page of this user guide for that) — this page documents the actual implementation: ACF structure, custom post type/taxonomy configuration, blocks, and the PHP hooks in the seventy theme, along with the non-obvious caveats that shaped their design.

All code referenced lives in the seventy theme (child/custom theme) inside this Bedrock-structured WordPress install:

  • web/app/themes/seventy/inc/helpers.php: the seventy_tel_href() helper and the breadcrumb-ancestor filter described below
  • web/app/themes/seventy/inc/block-loader.php: auto-registers every folder under /blocks/ containing a block.json
  • web/app/themes/seventy/blocks/person-* : the custom People blocks
  • web/app/themes/seventy/templates/single-person.html: the Single Person FSE template

1. ACF Structure

1.1 “Person Profile” field group (person post type)

Field groups on this site are managed via the ACF admin UI directly on each environment’s database, not via acf-json sync. There is no acf-json/ directory in this theme — see §5.1.

Field group name: Person Profile. Fields used by get_field() uses):

FieldTypeNotes
titletextThe person’s role/position — not their name. Name is the post’s own title (get_the_title()), unrelated to this field.
emailemailRendered by person-email as a mailto: link.
phone_numbertextRendered by person-phone as a tel: link — see §4.1.
linkurlRendered by person-link as a button, and consumed directly by person-query-outbound to decide its wrapper element — see §5.4.
link_labeltext, conditional on link being non-emptyButton text for person-link. Falls back to “More information” if blank.

1.2 Taxonomies

Three non-hierarchical taxonomies, all applied to person:

  • person-category: Staff, Board Members, Emeritus Board Members, Buchholz Fellows, Buchholz Alumni, Inactive. Drives the breadcrumb logic and the Person Categories block’s display logic. Editors should double-tag Emeritus Board Members and Buchholz Alumni to simplify archiving (for example, they need not remove existing tags to bump someone to “alumni”).
  • board-position: Board position such as Chair, Vice Chair, Secretary, Treasurer, if assigned.
  • buchholz-cohort: one term per fellowship year (2017-2018 through 2026 as of this writing).

1.3 “C70 Site Settings” ACF Options Page additions

Three fields exist in c70-site-settings options page (alongside all_events_page, documented in the Events technical guide) specifically for the breadcrumb logic:

  • about_us_page
  • staff_and_board_page
  • buchholz_fellows_page

All three are Page Link fields (same field type as all_events_page), returning a permalink URL string — resolve to a post ID with url_to_postid() if needed. Empty by default on a fresh environment and must be set manually per environment, same caveat as all_events_page.


2. Custom Post Type Registration (person)

Registered via ACF (UI-managed). Key configuration:

  • hierarchical: falsehas_archive: false.
  • rewrite.slug: person — flat, no path prefix (contrast with Events’ deliberately-namespaced slug). Permalinks look like /person/geoff-stewart/. There’s no known routing collision today (no Page lives at /person/), but the same collision class of bug documented in the Events technical guide (§2.1 there) would apply if one were ever created.
  • Taxonomies: person-categoryboard-positionbuchholz-cohort.
  • Single template: templates/single-person.html (FSE).

3. Templates & Blocks

3.1 templates/single-person.html

Structure: header → seventy/page-header (variant: singular) → two-column layout — main column: post-title (h1, the person’s name) → person-title (their role) → post-content (their bio, if any); sidebar column: person-sidebar.

All custom blocks are dynamic (render.php) and read postId/postType from block context, falling back to get_the_ID() when context isn’t supplied.

3.2 Block inventory

BlockPurposeNotes
person-titleDisplays the ACF title field (role), if set
person-emailmailto: link from the email field, if set
person-phonetel: link from phone_number, if setUses seventy_tel_href()
person-linkButton from the link/link_label fields, if setManually reproduces core Button block markup rather than nesting a real core/button
person-categoriesPlain (unlinked) <ul> of person-category termsEmeritus Board Members suppresses Board Members; Buchholz Alumni suppresses Buchholz Fellows
person-sidebarComposes photo, an “About [Name]” heading, person-titleperson-categories, conditional contact info, and person-link into the Single Person template’s sidebar“About [Name]” is plain PHP text, not a real core/post-title
person-query-outboundFor use as a Post Template item inside a core/query blockNever links to the person’s own single-person permalink

4. inc/helpers.php — Function & Hook Reference

4.1 seventy_tel_href($phone)

Not a hook — a small formatting helper called directly from person-phone/render.php. Strips everything except a leading + and digits (e.g. "(215) 557-3600" → "2155573600") for use as the tel: href, while leaving the displayed text exactly as entered in the phone_number field. Added specifically for this block; nothing else in the theme formats phone numbers.

4.2 seventy_add_person_breadcrumb_ancestors($breadcrumb_items)

Hooked to block_core_breadcrumbs_items: WordPress core’s native core/breadcrumbs block filter.

For a single person (is_singular('person')), reads the post’s person-category terms to decide a “bucket”:

  • staffboard-members, or emeritus-board-members → staff_and_board_page
  • buchholz-fellows or buchholz-alumni → buchholz_fellows_page
  • Both present → Staff and Board wins
  • Neither present → falls back to about_us_page alone (no third breadcrumb level)

Resolves the target page’s URL to a post ID via url_to_postid(), walks its real ancestor chain via get_post_ancestors() (same pattern as the Events implementation), and splices those Page crumbs in. Before splicing, it also strips whatever taxonomy-term item core auto-inserted — person is non-hierarchical with no post_parent, so core/breadcrumbs always falls back to whichever of board-position / buchholz-cohort / person-category (checked in that order — core’s get_object_taxonomies() registration order) has a term on the post first, producing inconsistent crumbs like “Chair” or a bare cohort year. The strip step matches by comparing each existing breadcrumb item’s URL against every term link across all three taxonomies on the current post, rather than assuming a fixed array position, since which taxonomy core happens to pick varies post to post.


5. Caveats & Warnings

5.1 ACF config is not version-controlled

Same caveat as the Events system (see the Events technical guide, §5.1) — no acf-json/ sync. The Person Profile field group, all three People taxonomies, and the three new Options Page fields are all database-stored, per-environment, and won’t exist on a fresh environment until manually recreated or imported.

5.2 Tag both the specific and general Person Category

person-categories and the breadcrumb logic both assume that Emeritus Board Members are also tagged Board Members, and Buchholz Alumni are also tagged Buchholz Fellows — not one or the other. Both only suppress the more general term from display when the more specific one is present; neither infers the general term from the specific one. If an editor tags someone “Emeritus Board Members” only, person-categories still displays correctly (just that one term), but anything that queries specifically for board-members in the future (a new Query Loop, for instance) would silently miss them.

This double-tagging convention is already applied to all imported data as of this writing, including one real overlap case worth knowing about: “Timothy Ford, Esquire” is tagged both board-members and buchholz-fellows/buchholz-alumni, because a separately-scraped Buchholz alumnus profile for the same person was identified as a duplicate of his existing board-member entry and manually merged (categories combined onto one post) rather than imported as a second post. If new person data is added in the future, check for this kind of duplicate before creating a new post — Committee of Seventy people frequently hold more than one role over time.

5.3 board-position is sparsely populated

As of this writing, the board-position taxonomy has exactly one person tagged per term (Chair, Vice Chair, Secretary, Treasurer — one each). This wasn’t backfilled for the full board roster; it only reflects the current officers identified during initial setup. Don’t assume it’s a complete or reliable dataset for querying “all board officers” without first confirming coverage.

This is deliberate, not an oversight. Many board member profiles were imported from external bio pages (an employer’s site, a LinkedIn profile) and have little or no real post_content — their link field is the only meaningful destination. person-query-outbound wraps its content in <a href="{link}"> when the link field is set, or a plain <div> when it isn’t — it never falls back to get_permalink(). If a future roster/listing page needs to link to a person’s own bio page instead, that’s a deliberate behavior change, not a bug fix.

5.5 person-link hand-rolls core Button block markup

person-link/render.php prints wp-block-buttons / wp-block-button / wp-block-button__link wp-element-button markup directly in PHP rather than composing a real nested core/button block. This keeps the block a single simple dynamic block (matching every other block in this system) but means it won’t automatically pick up future changes to how the Button block styles itself (e.g. a new button style variant added globally) — anyone updating button styling site-wide should check this file too.

5.6 “About [Name]” and heading level are rendered directly, not via nested core blocks

Two related, deliberate simplifications:

  • person-sidebar‘s “About [Name]” heading is plain PHP ('About ' . get_the_title($post_id)), not a real core/post-title block, because core’s Post Title block has no attribute for a static text prefix.
  • person-query-outbound‘s name heading is similarly rendered directly via PHP, using its own headingLevel attribute (default 3/h3, editable via a standard Inspector Control dropdown), rather than nesting a real core/post-title block with InnerBlocks. An InnerBlocks-based version was built first and then deliberately abandoned: core has no mechanism for a parent block’s attribute to drive a nested child block’s own attribute without hand-writing fragile two-way state sync in JavaScript. Rendering the heading directly in PHP, driven by one attribute on the block itself, was simpler and more reliable.

If a future developer is tempted to “restore” a real nested Post Title block in either case, revisit this reasoning first.

5.7 This site uses core core/breadcrumbs, not Yoast

Same distinction as documented in the Events technical guide: Yoast SEO is installed and registers its own yoast-seo/breadcrumbs block, but breadcrumb markup on this site resolves to WordPress core’s native core/breadcrumbs block. Any future breadcrumb customization — for People or anything else — must hook block_core_breadcrumbs_items, not a Yoast filter.

5.8 ServerSideRender does not forward block context — hit and fixed on person-query-outbound

This is the same Gutenberg limitation documented in the Events technical guide (§5.7 there), but worth restating because it produced a concrete, confirmed bug during this system’s build: person-query-outbound‘s editor preview initially showed the page it was inserted into (e.g. “Staff and Board”) instead of the actual looped person, because ServerSideRender‘s default REST call sends post_id from select('core/editor').getCurrentPostId() — the page being edited — not the postId supplied by an ancestor Post Template block. The frontend was never affected (real Query Loop rendering populates block context correctly; this is purely a REST/editor-preview code path). Fixed in person-query-outbound/index.js by explicitly passing urlQueryArgs: { post_id: contextPostId } (falling back to {} if no ancestor context is available yet), where contextPostId comes from the context prop edit() receives via usesContextAny new dynamic block meant for use inside a Query Loop must do the same.