PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.2
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / standalone / property-schema.php

property-schema.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.2, at includes/standalone/property-schema.php

267 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Standalone (theme_id 990) single-property JSON-LD schema.
4 *
5 * ONE consolidated graph built from the Property view model and emitted once in
6 * wp_head — not fragmented per section, so it stays valid no matter which
7 * sections were placed (build plan, decision 7). The array builder
8 * mlsimport_property_schema_from_vm() is pure (no WordPress, no DB) so it is
9 * unit-testable in isolation.
10 *
11 * The graph has two nodes because RealEstateListing descends from WebPage, not
12 * from Place: it is the advert, not the building. Physical facts (address, geo,
13 * bed/bath counts, floorSize, yearBuilt) are out of WebPage's domain and belong
14 * on a companion Accommodation node, joined to the listing via mainEntity/@id.
15 *
16 * @package Mlsimport
17 */
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 require_once __DIR__ . '/property-sections.php';
24
25 /**
26 * Build the JSON-LD schema array from a property view model. Pure + DB-free.
27 *
28 * @param array $vm Property view model (mlsimport_property_data()).
29 * @param string $currency ISO 4217 currency code for the offer.
30 * @return array Schema array (empty when there is nothing to describe).
31 */
32 function mlsimport_property_schema_from_vm( array $vm, string $currency = 'USD' ): array {
33 if ( empty( $vm ) ) {
34 return array();
35 }
36
37 // Node ids are fragments of the listing URL so the two nodes stay linkable
38 // even once this graph is merged with markup emitted by a theme or an SEO
39 // plugin. Without a permalink they are still unique within the document.
40 $base = ! empty( $vm['permalink'] ) ? (string) $vm['permalink'] : '';
41 $listing = array(
42 '@type' => 'RealEstateListing',
43 '@id' => $base . '#listing',
44 );
45
46 if ( ! empty( $vm['title'] ) ) {
47 $listing['name'] = $vm['title'];
48 }
49 if ( '' !== $base ) {
50 $listing['url'] = $base;
51 }
52
53 $description = '';
54 if ( ! empty( $vm['excerpt'] ) ) {
55 $description = trim( (string) $vm['excerpt'] );
56 } elseif ( ! empty( $vm['content'] ) ) {
57 $description = trim( strip_tags( (string) $vm['content'] ) );
58 }
59 if ( '' !== $description ) {
60 $listing['description'] = $description;
61 }
62
63 if ( ! empty( $vm['image_url'] ) ) {
64 $listing['image'] = $vm['image_url'];
65 }
66
67 // Offer.
68 if ( isset( $vm['price'] ) && null !== $vm['price'] ) {
69 $listing['offers'] = array(
70 '@type' => 'Offer',
71 'price' => (float) $vm['price'],
72 'priceCurrency' => $currency,
73 );
74 }
75
76 // Listing date. One of only two properties RealEstateListing defines itself,
77 // and it wants raw ISO-8601 — not the display-formatted $vm['updated'].
78 if ( ! empty( $vm['list_date'] ) ) {
79 $listing['datePosted'] = (string) $vm['list_date'];
80 }
81
82 // Listing agent. It hangs from `provider` (inherited from CreativeWork:
83 // "the party providing this item") and NOT from `broker` — broker is defined
84 // on Order/Invoice/Reservation and is invalid on RealEstateListing, which
85 // descends from WebPage.
86 if ( ! empty( $vm['agent']['name'] ) ) {
87 // A feed-sourced agent's vm carries the COMPANY contacts (#181) — those
88 // belong to the office, not to this RealEstateAgent node, so the node
89 // stays contact-less; only a local agent's own channels are emitted.
90 $is_feed = ! empty( $vm['agent']['is_feed'] );
91 $listing['provider'] = array_filter(
92 array(
93 '@type' => 'RealEstateAgent',
94 'name' => (string) $vm['agent']['name'],
95 'email' => ( ! $is_feed && ! empty( $vm['agent']['email'] ) ) ? (string) $vm['agent']['email'] : '',
96 'telephone' => ( ! $is_feed && ! empty( $vm['agent']['phone'] ) ) ? (string) $vm['agent']['phone'] : '',
97 ),
98 'strlen'
99 );
100 }
101
102 $residence = mlsimport_property_schema_residence_node( $vm, $base );
103 if ( ! empty( $residence ) ) {
104 $listing['mainEntity'] = array( '@id' => $residence['@id'] );
105 }
106
107 $graph = array( $listing );
108 if ( ! empty( $residence ) ) {
109 $graph[] = $residence;
110 }
111
112 return array(
113 '@context' => 'https://schema.org',
114 '@graph' => $graph,
115 );
116 }
117
118 /**
119 * The Accommodation node describing the dwelling itself: everything that is a
120 * fact about the building rather than about the advert.
121 *
122 * @param array $vm Property view model.
123 * @param string $base Listing URL, used as the @id base.
124 * @return array Node array, or empty when the feed said nothing physical.
125 */
126 function mlsimport_property_schema_residence_node( array $vm, string $base ): array {
127 $node = array(
128 '@type' => mlsimport_property_schema_accommodation_type( $vm ),
129 '@id' => $base . '#residence',
130 );
131
132 // The street line only — addressLocality/Region/postalCode carry the rest,
133 // so reusing the composed one-line address here would duplicate them. Feeds
134 // with no parsed street parts fall back to whatever address we could build.
135 $street = '';
136 if ( ! empty( $vm['street'] ) ) {
137 $street = (string) $vm['street'];
138 } elseif ( ! empty( $vm['address'] ) ) {
139 $street = (string) $vm['address'];
140 }
141
142 $address = array_filter(
143 array(
144 '@type' => 'PostalAddress',
145 'streetAddress' => $street,
146 'addressLocality' => ! empty( $vm['city'] ) ? (string) $vm['city'] : '',
147 'addressRegion' => ! empty( $vm['state'] ) ? (string) $vm['state'] : '',
148 'postalCode' => ! empty( $vm['zip'] ) ? (string) $vm['zip'] : '',
149 ),
150 'strlen'
151 );
152 if ( count( $address ) > 1 ) {
153 $node['address'] = $address;
154 }
155
156 if ( isset( $vm['latitude'] ) && null !== $vm['latitude'] && isset( $vm['longitude'] ) && null !== $vm['longitude'] ) {
157 $node['geo'] = array(
158 '@type' => 'GeoCoordinates',
159 'latitude' => (float) $vm['latitude'],
160 'longitude' => (float) $vm['longitude'],
161 );
162 }
163
164 if ( isset( $vm['bedrooms'] ) && null !== $vm['bedrooms'] ) {
165 $node['numberOfBedrooms'] = (float) $vm['bedrooms'];
166 }
167 if ( isset( $vm['bathrooms'] ) && null !== $vm['bathrooms'] ) {
168 $node['numberOfBathroomsTotal'] = (float) $vm['bathrooms'];
169 }
170 if ( isset( $vm['living_area'] ) && null !== $vm['living_area'] ) {
171 $node['floorSize'] = array(
172 '@type' => 'QuantitativeValue',
173 'value' => (float) $vm['living_area'],
174 'unitCode' => 'FTK', // Square foot.
175 );
176 }
177 if ( isset( $vm['year_built'] ) && null !== $vm['year_built'] ) {
178 $node['yearBuilt'] = (int) $vm['year_built'];
179 }
180
181 // Only @type and @id: the feed gave us nothing about the building.
182 return count( $node ) > 2 ? $node : array();
183 }
184
185 /**
186 * Pick the schema.org Accommodation subtype for a listing. Anything we cannot
187 * confidently place stays on the generic parent rather than being mislabelled
188 * as a house.
189 *
190 * @param array $vm Property view model.
191 * @return string Schema.org type name.
192 */
193 function mlsimport_property_schema_accommodation_type( array $vm ): string {
194 $haystack = strtolower( (string) ( $vm['property_sub_type'] ?? '' ) . ' ' . (string) ( $vm['property_type'] ?? '' ) );
195
196 foreach ( array(
197 'Apartment' => array( 'condo', 'apartment', 'co-op', 'coop', 'flat' ),
198 'House' => array( 'townhouse', 'town house', 'duplex', 'villa' ),
199 'SingleFamilyResidence' => array( 'single family', 'singlefamily', 'residential', 'house', 'detached' ),
200 ) as $type => $needles ) {
201 foreach ( $needles as $needle ) {
202 if ( false !== strpos( $haystack, $needle ) ) {
203 return $type;
204 }
205 }
206 }
207
208 return 'Accommodation';
209 }
210
211 /**
212 * Build the schema array for a property by id.
213 *
214 * @param int $id Property post ID (0 = current loop post).
215 * @return array
216 */
217 function mlsimport_property_schema( int $id = 0 ): array {
218 $vm = mlsimport_property_data( $id );
219 $schema = mlsimport_property_schema_from_vm( $vm );
220
221 /**
222 * Filter the assembled single-property JSON-LD schema array.
223 *
224 * @param array $schema Schema array.
225 * @param array $vm Property view model.
226 */
227 return (array) apply_filters( 'mlsimport_property_schema', $schema, $vm );
228 }
229
230 /**
231 * The schema as a ready-to-print <script type="application/ld+json"> tag.
232 *
233 * @param int $id Property post ID.
234 * @return string Script tag, or '' when there is nothing to emit.
235 */
236 function mlsimport_property_schema_script( int $id = 0 ): string {
237 $schema = mlsimport_property_schema( $id );
238 if ( empty( $schema ) ) {
239 return '';
240 }
241 return '<script type="application/ld+json">'
242 . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
243 . '</script>' . "\n";
244 }
245
246 /**
247 * wp_head hook: emit the consolidated schema on a single property view.
248 *
249 * Covers both modes. In live passthrough there is no post — the route has
250 * already stashed the fetched RESO record, and id 0 makes
251 * mlsimport_property_data() short-circuit to it through the
252 * mlsimport_property_data_pre seam.
253 *
254 * @return void
255 */
256 function mlsimport_property_print_schema(): void {
257 if ( is_singular( 'mlsimport_property' ) ) {
258 $id = (int) get_the_ID();
259 } elseif ( function_exists( 'mlsimport_live_current_record' ) && null !== mlsimport_live_current_record() ) {
260 $id = 0;
261 } else {
262 return;
263 }
264
265 echo mlsimport_property_schema_script( $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-LD; wp_json_encode escapes content.
266 }
267