JPKCOM_ACFJOBS_ABILITIES
public
mixed
JPKCOM_ACFJOBS_ABILITIES
= \true
public
mixed
JPKCOM_ACFJOBS_ABILITIES
= \true
Ability category slug, shared with the other JPKCom content plugins.
public
mixed
JPKCOM_ACFJOBS_ABILITY_CATEGORY
= 'jpkcom-content'
Number of listed jobs above which list-filters stops tallying.
public
mixed
JPKCOM_ACFJOBS_ABILITY_COUNT_LIMIT
= 500
Top-level input keys each ability declares.
public
mixed
JPKCOM_ACFJOBS_ABILITY_INPUT_KEYS
= ['jpkcom-acf-jobs/list-filters' => [], 'jpkcom-acf-jobs/query-jobs' => ['job_type', 'company', 'location', 'attribute', 'search', 'include_closed', 'page', 'per_page', 'order'], 'jpkcom-acf-jobs/get-job' => ['id']]
One list, in one place, because the call sites used to carry it inline and one of the three did not carry it at all. get-job accepted any undeclared key with a 200 while query-jobs and list-filters refused the same key with a 400 — measured over the REST route on WordPress 7.0.3. That is the trap the comment at the first call site already named: a caller that learns the refusal on one ability assumes it everywhere, and the one ability that silently accepts is the one it will trust.
On get-job an ignored key cannot widen a result set the way it can on
query-jobs — the answer is determined by id alone — so this is a
consistency defect rather than a wrong answer. It is still the shape that
teaches a caller the wrong rule.
Keep in step with the input schemas: tests/test-abilities.php compares
this map against the properties of every registered schema and fails the
build on either drift direction. Declaring additionalProperties => false
would derive the list automatically, but it was measured to preempt
jpkcom_acf_jobs_ability_validate_input_keys() entirely — validate_input()
runs before the execute callback — and core's replacement message names
neither the accepted keys nor where a nested filter belongs.
Field key of the job_type checkbox, the source of the filter vocabulary.
public
mixed
JPKCOM_ACFJOBS_ABILITY_JOB_TYPE_FIELD
= 'field_68de7a25cd78d'
Maximum number of values accepted per filter axis.
public
mixed
JPKCOM_ACFJOBS_ABILITY_MAX_VALUES
= 20
Highest page number the query ability will ask the database for.
public
mixed
JPKCOM_ACFJOBS_ABILITY_PAGE_MAX
= \intdiv(\PHP_INT_MAX, \max(1, \JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX))
Derived, not picked. WP_Query::get_posts() computes its LIMIT offset as absint( ( $page - 1 ) * $posts_per_page ), and that product is a plain PHP integer multiplication: past PHP_INT_MAX it becomes a float, and absint() casts rather than throws. Measured on WP 7.0.2 — page 1844674407370955161 at a page size of 10 collapses the offset to 0, so page ONE's records come back labelled as a page far beyond total_pages and a caller paginating on those numbers is handed the same records twice.
per_page is clamped to at most PER_PAGE_MAX before this bound is applied, so bounding the page at intdiv( PHP_INT_MAX, PER_PAGE_MAX ) keeps the product exact for every page size the ability accepts. max() guards the divisor: a site is free to redefine PER_PAGE_MAX, and intdiv() by zero is a fatal at file load.
Default page size of the query ability.
public
mixed
JPKCOM_ACFJOBS_ABILITY_PER_PAGE_DEFAULT
= 10
Maximum page size of the query ability.
public
mixed
JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX
= 50
Longest search term, in bytes, that WordPress will actually apply.
public
mixed
JPKCOM_ACFJOBS_ABILITY_SEARCH_MAX_BYTES
= 1600
Read out of wp-includes/class-wp-query.php:866-869 rather than guessed. The
guard there is an anti-DoS measure that silently empties s when it is not
scalar or longer than 1600 bytes, and it runs INSIDE WP_Query — after any
caller has finished inspecting the arguments it passed. The result is not a
crash: the search simply stops narrowing and every post matches.
Verified byte-identical on WordPress 6.9.4 (the declared floor) and 7.0.2, so there is no stricter of the two to take.
strlen(), so the unit is BYTES. Counting characters would hand a 900- character accented term to a guard that counts bytes and reproduce the defect for non-ASCII callers only.
Maximum number of company or location records list-filters offers.
public
mixed
JPKCOM_ACFJOBS_ABILITY_VOCABULARY_LIMIT
= 500
public
mixed
JPKCOM_ACFJOBS_BASENAME
= \plugin_basename(__FILE__)
public
mixed
JPKCOM_ACFJOBS_PLUGIN_PATH
= \plugin_dir_path(__FILE__)
public
mixed
JPKCOM_ACFJOBS_PLUGIN_URL
= \plugin_dir_url(__FILE__)
public
mixed
JPKCOM_ACFJOBS_VERSION
= '1.5.5'
Decide whether the abilities may be registered at all.
jpkcom_acf_jobs_abilities_enabled() : bool
ACF Pro is part of the condition, not an assumption. Requires Plugins
only blocks activation: WordPress does not stop an administrator from
deactivating a dependency that has active dependents, so one click is
enough to remove get_field() from under this file. Without that check the
first field read is Call to undefined function — an uncaught fatal in a
REST request on the 6.9 floor.
True when the Abilities API is present, ACF is active and the kill switch is on.
Record an abilities failure that WordPress itself reports silently.
jpkcom_acf_jobs_ability_log(string $message) : void
wp_register_ability() and wp_register_ability_category() return null on every failure path and report only through _doing_it_wrong(), which is silent in production. Debug-only on purpose: this exists to make a failed registration findable, not to fill a production log.
Message to record.
Force a map to encode as a JSON object rather than as an array.
jpkcom_acf_jobs_ability_json_object(array<string|int, mixed> $map) : array<string|int, mixed>|stdClass
PHP serialises an empty array as [], but filters, unknown and every
top-level input default are declared type: object. Core's REST list
controller special-cases exactly that value and rewrites it to }; the
MCP Adapter does not — it hands the schema to clients raw. So this wrapper
is required, not decorative.
Map that must not degrade to a JSON array.
The map, or an empty object when it is empty.
Build a WP_Error that carries an HTTP status.
jpkcom_acf_jobs_ability_error(string $code, string $message[, int $status = 400 ]) : WP_Error
The status is never optional in practice. The REST run controller returns the WP_Error verbatim and rest_ensure_response() defaults to 500 when data['status'] is absent — which tells an agent "transient server fault, retry unchanged", the exact opposite of the intended instruction. These messages exist so a caller can correct itself in one turn.
Machine-readable error code.
Human-readable message naming the valid form.
HTTP status to report. Default 400.
Error carrying the status in its data.
Resolve the capability required to run an ability.
jpkcom_acf_jobs_ability_capability(string $ability) : string
Defaults to read. Every query is hard-scoped to published, unprotected
jobs, so this cannot expose drafts or private content — but it is bulk
machine-readable access, which a site may want to restrict further.
Fully qualified ability name.
Capability name.
Build the meta array of an ability.
jpkcom_acf_jobs_ability_meta(string $ability) : array<string|int, mixed>
Three independent exposure switches live here. show_in_rest governs core
REST visibility. public seeds it from WordPress 7.1 onwards and is an
inert passthrough on 6.9 and 7.0. mcp is not a core key at all — it is
the MCP Adapter's own gate, and without it an ability is neither
discoverable nor executable over MCP.
All three annotations are set explicitly. They default to null, and the REST run controller derives the HTTP method from them, so an ability without annotations is POST-only.
Fully qualified ability name.
Meta array for wp_register_ability().
Read the registered job_type vocabulary as value => label.
jpkcom_acf_jobs_job_type_choices() : array<string|int, mixed>
Read from the field definition, never from stored values, so the enum is complete even for a type no job currently uses. The eight literals are a fallback rather than a second source of truth: they keep jpkcom_acf_jobs_get_ability_definitions() free of any dependency on a live ACF registry, which is what lets the CI harness assert the registration arrays without a WordPress installation.
The labels are locale-dependent and are never valid filter input. Only the keys are.
Map of job type value => human-readable label.
Resolve the language the answer is actually in.
jpkcom_acf_jobs_ability_language() : string
There is deliberately no lang input anywhere in this feature. Nothing
here can switch WPML's language context, and a declared parameter with
nothing behind it is a false statement in the schema: a client sending
lang=fr would receive German and have no way to notice. Reporting what
the site resolved is the honest half of that trade.
Both WPML accessors are guarded. wpml_current_language is the documented
filter and returns the value it was handed when WPML is absent, so no
plugin check is needed around it; ICL_LANGUAGE_CODE is read only after
defined(), because referencing an undefined constant is a fatal on PHP 8.
Language or locale code, empty only when WordPress itself cannot say.
List every published company or location this site offers as a filter value.
jpkcom_acf_jobs_ability_related_vocabulary(string $post_type) : array<string|int, mixed>
Deliberately independent of how many jobs exist. Deriving this list from the pass over the visible jobs would make its contents depend on the corpus size, so a caller would be offered a different filter menu on a large site than on a small one with nothing in the response explaining why. Only the counts depend on that pass; the vocabulary does not.
fields => 'ids' so the query itself carries no post rows, no_found_rows because nothing here needs a total, and both cache flags off because the caller primes exactly the meta it goes on to read. The titles and the second status check then come from jpkcom_acf_jobs_normalise_related(), which is the same projection every other reader in this feature uses and which never emits a WP_Post. A direct SELECT of ID and post_title would touch fewer columns, but this plugin issues no SQL of its own anywhere, and it would bypass that status and password recheck.
Either 'job_company' or 'job_location'.
{ @type array $records List of [ 'id' => int, 'title' => string ]. @type bool $truncated Whether the site holds more records than the cap. }
Count the posts matching a set of WP_Query arguments without fetching them.
jpkcom_acf_jobs_ability_count_query(array<string|int, mixed> $args) : int
fields => 'ids' plus a page size of one keeps the result to a single column of a single row while the total is still computed. no_found_rows is pinned to false because that total is the only thing this call exists to produce.
WP_Query arguments.
Number of matching posts.
Count the published jobs the site visibility rule excludes, by cause.
jpkcom_acf_jobs_ability_visibility_counts(int $published_total, int $listed_total) : array<string|int, mixed>
Two independent causes exclude a job that has no job_featured row: the EXISTS clause of the visibility rule, and the meta_key the ordering needs, whose postmeta.meta_key condition lands in the WHERE clause. Removing either one changes nothing, so subtracting a listed total from a published total would attribute the shortfall to whichever cause happened to be named. Each cause therefore gets its own query.
The expired count is conditioned on job_featured existing as well, so the two causes partition the difference rather than overlapping.
{ @type int $hidden_missing_featured Published jobs carrying no job_featured row. @type int $hidden_expired Published jobs whose expiry date has passed. }
Refuse a top-level input key the ability does not declare.
jpkcom_acf_jobs_ability_validate_input_keys(array<string, mixed> $input, array<string|int, string> $allowed) : true|WP_Error
Neither input schema declares additionalProperties, so an unrecognised key
reaches the callback and is simply not read. The observable result was the
complete unfiltered corpus behind an HTTP 200, with filters correctly
omitting what it had not applied and unknown empty - so the caller had to
notice an absence to notice the failure, and one that trusts total reports
the whole corpus as a filtered answer.
Two routes led there and both are common. The output schema of query-jobs instructed the model to send a work_type filter that has never existed as an input, and a single transposed letter in a real axis behaved identically.
Raw ability input.
Declared input keys.
True when every key is declared, WP_Error otherwise.
Read a boolean the way a GET query string can actually express one.
jpkcom_acf_jobs_ability_normalise_bool(mixed $value) : mixed
Mirrors core's rest_sanitize_boolean() rather than inventing a set: those are the spellings every other WordPress REST endpoint accepts, so a caller that has learned one surface has learned this one. Anything outside them comes back unchanged, so the caller still gets the 400 - widening what is accepted must not turn into guessing what was meant.
Raw input value.
A bool when the value has a boolean reading, the input otherwise.
Clamp a requested page size into the range the query ability allows.
jpkcom_acf_jobs_ability_clamp_per_page(mixed $value) : int
Never returns -1 or 0. The shortcode's own default IS -1 and the shared builder refuses to default to it, but this clamp is what keeps an API caller from reaching an unbounded query in the first place.
Clamping rather than refusing is deliberate here, and it is not the same decision as the one the filter axes take. The response echoes the applied page size back, so a caller can see what it got; a silently dropped filter clause has no such tell, which is why that case is an error instead.
Requested page size.
Page size between 1 and JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX.
Validate one filter axis, refusing anything that would normalise away.
jpkcom_acf_jobs_ability_normalise_filter(mixed $raw, string $axis, int $max) : array<string|int, mixed>|WP_Error
This is the load-bearing guard of the whole ability. The shortcode builds each clause as array_filter( array_map( 'absint', … ) ) and skips it when the result is empty, so company=["acme"] adds no clause at all and the response contains EVERY job — the same class that answered with 19 of 19 posts in jpkcom-post-filter. A requested filter that survives normalisation empty is therefore an error naming the valid form, never a dropped clause.
A well-formed value that simply matches nothing is a different statement and
is not handled here: the caller resolves it and reports it in unknown,
because an empty result for company=[999] is honest.
Only the shape is decided here. Whether a well-formed id or slug exists on
this site is resolved at the call site, where get_post_type() and
get_term_by() are available and where the unknown bucket lives.
Raw value as it arrived in the ability input.
Axis name: 'job_type', 'company', 'location' or 'attribute'.
Maximum number of values this axis accepts.
Normalised values, [] when the axis was not requested, or an error.
Whether a meta query carries any clause on a meta key.
jpkcom_acf_jobs_ability_has_meta_clause(mixed $meta_query, string $key[, int $depth = 0 ]) : bool
This answers one question only, and it is a question about the ability's OWN construction: did the shared builder turn the request into a clause at all. It is asked before jpkcom_acf_jobs_ability_query_args runs, because after that filter the question is no longer "is something there" but "is what is there what this ability built" — which is decided by identity, in jpkcom_acf_jobs_ability_query_divergence(), and not by inspecting properties.
Deliberately shape-tolerant. The builder is resolved through the plugin's file override chain and the spec's promise is that this ability runs the same query the site itself runs, so how a site spells its own clause is its business; that the clause exists for a filter the response is about to CLAIM is not.
The depth limit is not decoration: unbounded recursion on a deep array is a stack overflow, which no ability callback may risk.
Meta query.
Meta key to look for.
Current recursion depth. Internal.
True when at least one clause names the key.
Report the first filter the response would claim that the query does not carry.
jpkcom_acf_jobs_ability_unbacked_claim(array<string|int, mixed> $args, array<string|int, mixed> $claims, bool $attribute, string $search, string $order) : string
A precondition on the ability's own construction, asked before any site
callback can touch the arguments. The shared builder is resolved through the
plugin's file override chain and skips a clause whose value list came out
empty, so a request that never became a clause would be answered with every
job on the site while filters names the axis as applied — the defect this
whole feature exists to prevent.
It asks presence and nothing else, on purpose. What a clause has to survive between here and WP_Query is decided by identity in jpkcom_acf_jobs_ability_query_divergence(); what a site's own builder chooses to build is the site's business, and the spec's promise is that this ability runs the same query the site runs.
Nothing here casts an unvalidated value: a cast of an object without __toString is a Throwable, and a Throwable out of an ability callback is an uncaught fatal on the declared 6.9 floor.
WP_Query arguments as the ability built them.
Meta keys that must each carry a clause, keyed by the label to report.
Whether a job-attribute clause is required.
Search term that must have reached the query, '' when none was requested.
Direction both sort components must carry.
Label of the first unbacked claim, or '' when every claim is carried.
Reduce any query fragment to one string that stands for its exact content.
jpkcom_acf_jobs_ability_canonical(mixed $value[, int $depth = 0 ]) : string
Two fragments produce the same string when they carry the same values under the same keys, and a different one as soon as anything about them differs — a value, an operator, a cast, an added key, an added clause, a changed type. That is the whole point: this ability cannot enumerate what a site callback might change about a clause, and it does not have to, because it knows what it built and can recognise it again.
Three properties are load-bearing:
Strings are length-prefixed so that no punctuation inside a value can imitate the structure around it.
Nothing here throws for any input. No value is cast to string — a cast of an object without __toString is a Throwable, and a Throwable out of an ability callback is an uncaught fatal on the declared 6.9 floor — and the recursion is depth-limited. The limit is far above anything this ability builds (its deepest fragment is a clause inside a group, at depth two), so a commitment can never itself be truncated, and a filtered fragment that is deeper than the limit differs from the commitment at a shallower level in any case.
Fragment to reduce.
Current recursion depth. Internal.
Canonical representation of the fragment.
Name a query fragment for an error message a site owner has to act on.
jpkcom_acf_jobs_ability_group_label(mixed $fragment) : string
Descriptive only. Nothing about the guard depends on it: a fragment nobody anticipated is still committed and still compared, it is merely reported under a general name.
Query fragment.
Human-readable name.
Record the query the ability is about to hand to the site, clause by clause.
jpkcom_acf_jobs_ability_query_commitments(array<string|int, mixed> $args) : array<string|int, mixed>
Taken immediately before jpkcom_acf_jobs_ability_query_args runs, so that what comes back can be compared against it rather than interrogated. Four review rounds asked a longer list of questions of each clause every time — presence, then compare, then type, then the enclosing relation — and every round found a question the previous one had not thought to ask. A value was still not among them, and neither was a clause added inside an OR group. The list of things that can be altered about a clause is not bounded by what anyone thought of; the set of clauses this ability built is.
Committed: every top-level element of meta_query and of tax_query. Those two structures are the whole of what a callback may contribute, so they are the whole of what can come back changed — every other query var is simply never read from the filtered array, which is a stronger guarantee than any comparison and needs no list of names to hold.
WP_Query arguments as the ability built them.
{ @type array $meta List of [ 'label' => string, 'canonical' => string ] for meta_query. @type array $tax The same for tax_query. }
Report the first commitment the executed query does not keep.
jpkcom_acf_jobs_ability_query_divergence(array<string|int, mixed> $committed, mixed $args) : string
The rule has two halves and both are established here rather than assumed.
Every clause this ability built has to be present, byte for byte as it was built, as a direct element of the same group. Anything else about it — a value trimmed, a boundary moved, a term list widened, one more value added inside an OR group, an operator flipped, a key nobody has thought of yet — makes it a different clause, and a different clause is not the one the response is about to name as applied.
What a callback may still do is ADD, and that permission rests on the relation of the group it adds to: under AND every further element can only remove rows, which is the one direction that cannot turn the response into a false claim. So the relation is verified on the arguments that actually run, and an addition anywhere else is refused by the paragraph above — an extra value inside an axis OR group widened FULL_TIME from 2 jobs to 4 on /home/jpk/ddev/posts while the response still claimed FULL_TIME.
A commitment is consumed once it is matched, so two committed clauses need two elements to satisfy them.
Nothing here throws for any input: every comparison is between two strings produced by jpkcom_acf_jobs_ability_canonical().
Output of jpkcom_acf_jobs_ability_query_commitments().
WP_Query arguments as they will reach WP_Query.
Name of the first divergence, or '' when the query is the one that was built.
Read the relation of a meta_query or tax_query group.
jpkcom_acf_jobs_ability_group_relation(mixed $group) : string
Absent means AND, which is what both WP_Meta_Query and WP_Tax_Query default to, so a missing relation is not a divergence.
Query group.
'AND' or the uppercased relation as given.
Permission callback for jpkcom-acf-jobs/list-filters.
jpkcom_acf_jobs_ability_permission_list_filters([mixed $input = null ]) : bool
Validated ability input. Unused.
True when the current user may run the ability.
Permission callback for jpkcom-acf-jobs/query-jobs.
jpkcom_acf_jobs_ability_permission_query_jobs([mixed $input = null ]) : bool
Validated ability input. Unused.
True when the current user may run the ability.
Permission callback for jpkcom-acf-jobs/get-job.
jpkcom_acf_jobs_ability_permission_get_job([mixed $input = null ]) : bool
Validated ability input. Unused.
True when the current user may run the ability.
Build the registration arguments for every ability this plugin provides.
jpkcom_acf_jobs_get_ability_definitions() : array<string|int, mixed>
Touches no registry and reads no WordPress state beyond __() and the jpkcom_acf_jobs_ability_meta filter, which is what lets the CI harness assert these arrays without a WordPress installation. Not free of side effects, though: __() and the three meta calls each fire apply_filters(), so third-party callbacks run whenever this is called.
Every output property is optional. A theme may replace includes/acf-field_groups.php through the plugin's file override system, after which ACF falls back to the raw meta and several values arrive in a different shape.
Ability name => wp_register_ability() arguments.
Execute callback for jpkcom-acf-jobs/list-filters.
jpkcom_acf_jobs_ability_list_filters_inner([mixed $input = null ]) : array<string|int, mixed>|WP_Error
Answers "which values does query-jobs accept, and how much of this site do they actually reach". The job type vocabulary comes from the field definition rather than from stored values, so it is complete even for a type nobody uses today; the attributes come from the term relations, because load_terms makes ACF discard the stored meta and a slug read from that meta may name a term the job no longer carries.
The counts and the visibility summary come from one pass over the listed job IDs rather than from one query per value.
Validated ability input. This ability takes none.
The filter vocabulary, or an error.
Execute callback for jpkcom-acf-jobs/query-jobs.
jpkcom_acf_jobs_ability_query_jobs_inner([mixed $input = null ]) : array<string|int, mixed>|WP_Error
Answers "which jobs does this site list, narrowed by these filters" with the same query the site itself runs, plus three things the site never needed:
filters.order reports back, and the tiebreaker — so a site
may add to the query, but not re-sort what it answers with.Per-property defaults are resolved here. Core applies only the top-level
default, and only when the input is exactly null.
Nothing in this function throws for any input, and nothing casts an
unvalidated value: the shared builder casts order to string, which throws
for an object without __toString, and a Throwable out of an ability callback
is an uncaught fatal on the declared 6.9 floor.
Validated ability input.
The result set, or an error.
Ask the site visibility rule whether it returns this one job.
jpkcom_acf_jobs_ability_job_is_listed(int $post_id) : bool
The verdict comes from the rule itself, restricted to a single post ID,
rather than from a second reading of the same meta in PHP. Spec section 5.3
defines listed as whether the job satisfies the visibility rule, and the
visibility rule is jpkcom_acf_jobs_build_job_query_args() — not a paraphrase
of it.
A paraphrase was measured to be wrong, not merely fragile. MariaDB casts a stored job_expiry_date of '2025-11-30 00:00:00' to the DATE 2025-11-30 through the rule's own type => 'DATE' comparison and drops the job, while jpkcom_acf_jobs_normalise_date() refuses that spelling outright and every PHP reading of it concludes "not expired". get-job then reported listed: true for a job query-jobs would not return — two abilities in one feature answering the same question differently. Asking the query removes the entire class rather than that one instance of it: any later change to the rule is reflected here automatically, and no copy of it can drift again.
The jpkcom_acf_jobs_ability_query_args filter is deliberately NOT applied. It exists so a site can shape what query-jobs lists; applying it to a verdict about the site's own rule would let a callback make this answer disagree with the rule it reports on.
Job post ID.
True when the site visibility rule returns this job.
Decide whether a job's own page would render for an anonymous visitor.
jpkcom_acf_jobs_detail_page_renders(int $post_id) : bool
This is the question the detail block depends on, and it is not the same question as "is this job listed". Address, salary, attributes and application data are public only as a side effect of a job's detail page rendering, so for a job that has no such page they were never published to anybody and emitting them to a logged-in subscriber would publish data the site has deliberately never shown. A job that is merely absent from every listing has a page like any other and keeps its detail block.
Three states answer false, and each of them is a redirect in includes/redirects.php:
The job_url test mirrors redirects.php literally: ! empty() on the RAW value, NOT the trimmed value the reader compares. A url of " " is not empty, so the site really does redirect to it, while a reader that trims first concludes the job does not redirect at all and returns everything. That difference is the whole reason this predicate exists as a second, independent decision rather than as a comment on the reader.
The expiry test deliberately does NOT mirror redirects.php literally. redirects.php compares the stored value against a Y-m-d today as strings, and ACF hands out Ymd whenever the field's key reference row is missing — the state includes/wpml-acf-field-keys-fix.php exists to repair — so '20251130' < '2026-01-15' holds only by accident of the same leading digits. The shared normaliser is used instead, which is also what the visibility rule compares.
Nothing here throws for any input, and a false is always the safe answer: the caller may only ever narrow what it emits on the strength of it.
Job post ID.
True when a visitor would be served the job's own page.
Execute callback for jpkcom-acf-jobs/get-job.
jpkcom_acf_jobs_ability_get_job_inner([mixed $input = null ]) : array<string|int, mixed>|WP_Error
Answers "everything this site publishes about job N", including the detail
data only its own page carries, and answers it for jobs no listing contains —
that is what separates this ability from a query-jobs of one result. A job
with no job_featured row, or an expired one, is resolvable here with listed
false and the reason; applying the visibility rule as an existence test
instead would leave "why does job 42 appear in no list" unanswerable by any
ability.
Two rules hold this callback together:
Validated ability input.
The job record, or an error.
Register the shared JPKCom content ability category.
jpkcom_acf_jobs_register_ability_category() : void
Defensive on purpose. Categories are global and first-wins, the loser of a collision gets a silent null, and an ability registered into an unregistered category is not registered at all. jpkcom-post-filter ships the same slug, so on any site running both plugins one of the two always loses the race.
Register every ability this plugin provides.
jpkcom_acf_jobs_register_abilities() : void
wp_register_ability() returns null on every failure path and reports only through _doing_it_wrong(), which is silent in production, so each result is checked rather than assumed.
Run an ability body and convert any Throwable into a WP_Error
jpkcom_acf_jobs_ability_boundary(callable $body, string $ability) : array<string, mixed>|WP_Error
The declared floor is WordPress 6.9, which has no Throwable-to-WP_Error wrapper - that landed in 7.0 - so an exception escaping a callback there is an uncaught fatal: a blank 500 with no body, no code and nothing a client can act on, triggerable by any logged-in subscriber. This file's own docblock already promises that every callback returns a WP_Error rather than throwing. Until this function existed, that promise covered the plugin's own arithmetic and not the reads.
It is a boundary rather than a set of shape checks on purpose. The throw happens INSIDE ACF while it reads - acf_maybe_get() for checkbox and select, acf_field_flexible_content->load_value() for layout content - so nothing on this side of the call can inspect the value first, the file's "read unformatted" rule does not reach the load path, and the set of shapes ACF cannot tolerate belongs to ACF and changes with it. An enumeration of fields or of corrupt shapes would be the same mistake the query post-condition made four times before it was replaced by one rule that needs no list.
The two paths that can degrade instead of failing do so before reaching here: query-jobs skips the unreadable job and counts it, and get-job answers as it does for an id that resolves to nothing. This catches what neither foresaw.
The message is deliberately generic. The exception text used to reach every logged-in subscriber over REST and every MCP client as an isError block, and a raw PHP engine string is neither something a caller can act on nor something a site owner wants published; it goes to the log instead.
The ability body to run.
Ability name, for the log line.
The body's result, or an error.
list-filters, behind the callback boundary
jpkcom_acf_jobs_ability_list_filters([mixed $input = null ]) : array<string, mixed>|WP_Error
Validated ability input.
The vocabulary, or an error.
query-jobs, behind the callback boundary
jpkcom_acf_jobs_ability_query_jobs([mixed $input = null ]) : array<string, mixed>|WP_Error
Validated ability input.
The listing, or an error.
get-job, behind the callback boundary
jpkcom_acf_jobs_ability_get_job([mixed $input = null ]) : array<string, mixed>|WP_Error
Validated ability input.
The record, or an error.
Render disable archive checkbox field
jpkcom_acf_jobs_disable_archive_field() : void
Render archive redirect URL field
jpkcom_acf_jobs_redirect_url_field() : void
Render Options admin page
jpkcom_acf_jobs_options_page() : void
Output Bootstrap 5 breadcrumb navigation
jpkcom_acf_jobs_breadcrumb() : void
Generates breadcrumb navigation for:
Includes proper ARIA labels and semantic HTML5 markup.
Outputs HTML directly.
Renders all ACF fields of a post with Bootstrap 5 markup and smart icons
jpkcom_render_acf_fields([string $post_type = '' ]) : void
Automatically detects field types and renders them with appropriate styling:
Optional. Post type for field group query. Default empty (uses current post type).
Get ACF field label by field key or field name
acf_get_field_label(string $field_key_or_name) : string
Attempts to retrieve the field label from ACF. If not found, returns a formatted fallback based on the field name/key.
Field key (e.g., 'field_abc123') or field name (e.g., 'job_title').
Field label or formatted fallback string.
Get ACF field label with enhanced search capabilities
jpkcom_get_acf_field_label(string $field_name_or_key[, string $post_type = '' ]) : string
Searches for field labels in this order:
Field name (e.g., 'job_title') or field key (e.g., 'field_abc123').
Optional. Post type for context-specific field group search. Default empty.
Field label or formatted fallback string.
Convert timestamp to human-readable relative date string
jpkcom_human_readable_relative_date(int $timestamp) : string
Converts Unix timestamps into relative date strings like:
All strings are translatable via the 'jpkcom-acf-jobs' text domain.
Unix timestamp to convert.
Translated relative date string.
Build WP_Query arguments for the job visibility rule.
jpkcom_acf_jobs_build_job_query_args([array<string|int, mixed> $args = [] ]) : array<string|int, mixed>
This is the single source for "which jobs does this site show". It is called from three places that previously each carried their own copy: the [jpkcom_acf_jobs_list] shortcode, the job archive's pre_get_posts handler, and the Abilities API callbacks.
Keys whose value is null are omitted from the result, because the archive sets its query through WP_Query::set() and must not inherit a page size or a status.
{ Optional. Query parameters.
@type int|null $posts_per_page Page size. Omitted when null. Never defaulted to -1.
@type int|null $paged Page number. Omitted when null.
@type string $order 'ASC' or 'DESC' for the date component. Default 'DESC'.
@type string|null $post_status Post status. Omitted when null.
@type string[] $job_type job_type values (not labels).
@type int[] $company job_company post IDs.
@type int[] $location job_location post IDs.
@type int[] $attribute job-attribute term IDs.
@type string $search Free-text search.
@type bool $exclude_password_protected Whether to drop password-protected jobs.
}
WP_Query arguments.
Normalise an ACF choice-list value to {value,label} pairs.
jpkcom_acf_jobs_normalise_choices(mixed $value) : array<string|int, mixed>
A checkbox with return_format 'array' yields [ ['value'=>…, 'label'=>…], … ]. The same field yields bare strings when ACF falls back to the raw meta, which happens whenever the field group is not registered — a theme replacing acf-field_groups.php through the override system is enough. Both shapes are the contract, not a defensive afterthought.
Raw ACF value.
List of [ 'value' => string, 'label' => string ].
Normalise a single-choice ACF value (button_group, select) to one pair.
jpkcom_acf_jobs_normalise_choice(mixed $value) : array<string|int, mixed>|null
Raw ACF value.
[ 'value' => string, 'label' => string ], or null when empty.
Normalise a stored date to Y-m-d.
jpkcom_acf_jobs_normalise_date(mixed $raw) : string|null
ACF stores date fields as Ymd and formats them on read, so both spellings reach this function depending on whether the field's key reference row exists. Anything else yields null rather than a guess.
Deliberately not written as date( 'Y-m-d', strtotime( $raw ) ), the form used in includes/schema.php:71: under strict_types a false from strtotime() makes date() throw a TypeError, and on the WP 6.9 floor a Throwable out of an ability callback is an uncaught fatal.
is_string() is not enough to make createFromFormat() safe: a PHP string can carry an embedded NUL byte anywhere in it, and as of PHP 8.3 that makes DateTimeImmutable::createFromFormat() throw ValueError instead of returning false — confirmed regardless of the NUL's position or which of the two formats below is tried. MySQL longtext happily stores one; an importer, WP-CLI, a WPML copy or direct SQL against job_expiry_date is enough to plant it. The try/catch below is what closes that door; everything else that can go wrong here (invalid UTF-8, absurdly long input, an overflowing month/day/date) was measured to fail closed already — createFromFormat() returns false rather than throwing, and format() never throws for the two hardcoded, always-valid format strings used here.
Stored value.
Date as Y-m-d, or null.
Reduce stored markup to plain text without executing anything.
jpkcom_acf_jobs_plain_text(mixed $value) : string
job_short_description is a textarea with new_lines => 'br', so its stored value is HTML. This turns it back into text. It expands nothing: shortcode expansion is exactly what get_field()'s formatted mode does and what every caller of this function exists to avoid.
Stored value.
Plain text, empty when the value was not a string.
Project ACF post-object values to {id,title}, dropping anything unpublished.
jpkcom_acf_jobs_normalise_related(mixed $value) : array<string|int, mixed>
Never returns a WP_Post. WP_Post implements no JsonSerializable and exposes post_password, post_content and post_status as public properties, so encoding one would publish a related company's plaintext password. ACF resolves post_object fields through acf_get_posts() with post_status 'any', so drafts and private records genuinely arrive here.
Bare integers are accepted and re-resolved: when a translation's ACF key reference row is missing — the case includes/wpml-acf-field-keys-fix.php exists to repair — get_field() returns raw IDs, and every renderer in this repo dereferences ->ID on them.
A bare id is rejected before the lookup when absint() reduces it to less than 1.
absint() maps false, '', null, 'abc' and 0 all to 0, and real get_post( 0 ) treats
0 as empty and falls back to the current global post — the same footgun
jpkcom_acf_jobs_get_job_data()'s own $post_id < 1 guard exists for. ACF genuinely
returns false, not an array, for an unassigned post_object field with
allow_null => 1, which both job_company and job_location are, so an unresolved id
reaching get_post() unguarded would project the current job as its own employer or
location. A password is checked for the same reason the reader's own gate checks
one: nothing about a post_object relation implies the related post is public.
Raw ACF value.
List of [ 'id' => int, 'title' => string ].
Resolve an ACF image value to a single URL.
jpkcom_acf_jobs_attachment_url(mixed $value) : string|null
ACF hands out an image field either as an attachment ID or as an array of roughly thirty keys, depending on the field's return format and on whether the field group is registered at all. Both shapes reduce to one URL here. The full array is deliberately never emitted: it carries the uploader's name, the file path on disk and every registered intermediate size, none of which a job listing needs.
Raw ACF image value.
Image URL, or null when the value resolves to no attachment.
Read one job as a JSON-serialisable array.
jpkcom_acf_jobs_get_job_data(int $post_id[, bool $full = false ]) : array<string|int, mixed>
The gate is the first act, because no field reader in this plugin has one: schema.php checks the post type, nothing anywhere checks the status, and the existing readers are safe only because the shortcode hands them posts a post_type/post_status query already filtered. A function taking a bare int inherits none of that, and current_user_can( 'read' ) is every logged-in user.
Returns [] for "does not exist" and for "not readable" alike, so the ability on top of it cannot be used to probe which IDs exist.
Job post ID.
Whether to include the detail fields (§5.3 of the spec).
The record, or [] when the job is not readable.
Register custom image sizes for job posts
jpkcom_acf_jobs_media_size() : void
Registers three image sizes:
Add custom image sizes to media library size selector
jpkcom_acf_jobs_image_sizes_to_selector(array<string|int, string> $sizes) : array<string|int, string>
Makes custom image sizes available in the WordPress media library dropdown when inserting images into posts.
Existing image size options.
Modified array with custom sizes added.
Output Bootstrap 5 pagination navigation
jpkcom_acf_jobs_pagination([string|int $pages = '' ][, int $range = 2 ]) : void
Generates numbered pagination with first/last and prev/next controls. Includes proper ARIA labels and accessible markup.
Features:
Optional. Total number of pages. Default empty (auto-detect from query).
Optional. Number of page links to show on either side of current page. Default 2.
Outputs HTML directly.
Generate Schema.org JobPosting JSON-LD for a single job post
jpkcom_acf_jobs_get_schema_job_posting([int|null $post_id = null ]) : string
Creates a complete JobPosting schema including:
The schema can be filtered using the 'jpkcom_acf_jobs_schema_job_posting' hook.
Optional. Post ID of the job post. Default null (uses current post).
JSON-LD formatted string ready for output in <script> tag, or empty string on failure.
jpkcom_acf_jobs_locate_template(string $template_name) : string|false
Locate template file with override support
jpkcom_acf_jobs_locate_template(string $template_name) : string|false
Searches for template files in this priority order:
Template filename (e.g., 'single-job.php' or 'partials/job/company.php').
Full path to template file if found, false otherwise.
Template loader for singular and archive templates
jpkcom_acf_jobs_template_include(string $template) : string
Intercepts WordPress template_include filter and loads custom templates for job, job_company, and job_location post types (single and archive views).
Default template path from WordPress.
Template path to use (plugin template or default).
Load partial templates with full override support
jpkcom_acf_jobs_get_template_part(string $slug[, string $name = '' ]) : void
Similar to WordPress get_template_part() but uses the plugin's template hierarchy system. Useful for loading reusable template partials.
Example usage: jpkcom_acf_jobs_get_template_part('partials/job/company'); jpkcom_acf_jobs_get_template_part('partials/job/company', 'detailed');
Template slug (e.g., 'partials/job/company').
Optional. Template name/variation (e.g., 'alternative'). Default empty.
Load plugin text domain for translations
jpkcom_acfjobs_textdomain() : void
Loads translation files from the /languages directory.
Locate file with override support
jpkcom_acfjobs_locate_file(string $filename) : string|null
Searches for a file in multiple locations with priority:
The filename to locate (without path).
Full path to the file if found, null otherwise.