← back to Homesonspec
collectors: add Meritage Homes adapter (Sitecore Discover national JSON feed)
aa57e14f96e03bae67f13509efdacbb024445875 · 2026-07-28 15:54:57 -0700 · Steve Abrams
Reverse-engineered the Meritage inventory API: a single paginated Sitecore
Discover POST (discover.sitecorecloud.io/discover/v2/173266879, content_grid
widget) returns the whole company inventory (~2529 homes, 12 states) with
offset/limit pagination, no auth header required. Adapter emits community-then-
home records per QMI; facts-only (image_urls dropped). Test run (PAGE_LIMIT=15):
1488 homes, 100% price/beds/baths/sqft/zip, 96% lat/lon, 1488 distinct streets.
Added LiveFetcher.postJson() so the POST feed still rides the same rails as GET
(honest UA, robots.txt, per-source rate limit, stop-on-403/401/429). Wired into
the workers CLI + package.json. Source left inactive/PAUSED pending activation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/workers/package.jsonM apps/workers/src/cli.tsM collectors/common/src/live-fetch.tsA collectors/meritage-homes/package.jsonA collectors/meritage-homes/src/index.tsA collectors/meritage-homes/tsconfig.jsonM pnpm-lock.yaml
Diff
commit aa57e14f96e03bae67f13509efdacbb024445875
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 15:54:57 2026 -0700
collectors: add Meritage Homes adapter (Sitecore Discover national JSON feed)
Reverse-engineered the Meritage inventory API: a single paginated Sitecore
Discover POST (discover.sitecorecloud.io/discover/v2/173266879, content_grid
widget) returns the whole company inventory (~2529 homes, 12 states) with
offset/limit pagination, no auth header required. Adapter emits community-then-
home records per QMI; facts-only (image_urls dropped). Test run (PAGE_LIMIT=15):
1488 homes, 100% price/beds/baths/sqft/zip, 96% lat/lon, 1488 distinct streets.
Added LiveFetcher.postJson() so the POST feed still rides the same rails as GET
(honest UA, robots.txt, per-source rate limit, stop-on-403/401/429). Wired into
the workers CLI + package.json. Source left inactive/PAUSED pending activation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/workers/package.json | 4 +-
apps/workers/src/cli.ts | 4 +
collectors/common/src/live-fetch.ts | 50 +++++
collectors/meritage-homes/package.json | 15 ++
collectors/meritage-homes/src/index.ts | 323 ++++++++++++++++++++++++++++++++
collectors/meritage-homes/tsconfig.json | 1 +
pnpm-lock.yaml | 28 +++
7 files changed, 424 insertions(+), 1 deletion(-)
diff --git a/apps/workers/package.json b/apps/workers/package.json
index 22dd9b3f..9ccb0fbb 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -29,7 +29,9 @@
"@homesonspec/collector-taylor-morrison": "workspace:*",
"@homesonspec/collector-discovery": "workspace:*",
"@homesonspec/collector-david-weekley": "workspace:*",
- "@homesonspec/collector-ashton-woods": "workspace:*"
+ "@homesonspec/collector-ashton-woods": "workspace:*",
+ "@homesonspec/collector-dream-finders": "workspace:*",
+ "@homesonspec/collector-meritage-homes": "workspace:*"
},
"devDependencies": {
"tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index b3fd471e..e5541380 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -10,6 +10,8 @@ import { taylorMorrisonAdapter } from "@homesonspec/collector-taylor-morrison";
import { discoveryAdapter } from "@homesonspec/collector-discovery";
import { davidWeekleyAdapter } from "@homesonspec/collector-david-weekley";
import { ashtonWoodsAdapter } from "@homesonspec/collector-ashton-woods";
+import { dreamFindersAdapter } from "@homesonspec/collector-dream-finders";
+import { meritageHomesAdapter } from "@homesonspec/collector-meritage-homes";
import { runPipeline } from "./pipeline";
import { recordSourceRun } from "./verify";
@@ -29,6 +31,8 @@ const ADAPTERS = {
[discoveryAdapter.key]: discoveryAdapter,
[davidWeekleyAdapter.key]: davidWeekleyAdapter,
[ashtonWoodsAdapter.key]: ashtonWoodsAdapter,
+ [dreamFindersAdapter.key]: dreamFindersAdapter,
+ [meritageHomesAdapter.key]: meritageHomesAdapter,
};
async function main() {
diff --git a/collectors/common/src/live-fetch.ts b/collectors/common/src/live-fetch.ts
index e153bb8b..337ab4e0 100644
--- a/collectors/common/src/live-fetch.ts
+++ b/collectors/common/src/live-fetch.ts
@@ -173,6 +173,56 @@ export class LiveFetcher {
contentHash: sha256(body),
};
}
+
+ /**
+ * POST a JSON body and return the JSON response as a RawPage. Same rails as
+ * fetch(): honest UA (never disguised), robots.txt enforcement, per-source
+ * rate-limit, and STOP-on-403/401/429 (bot protection is never circumvented).
+ * No cookies, no login, no media. Added for JSON-search-API builders whose
+ * inventory feed is a POST (e.g. Sitecore Discover) rather than a GET —
+ * the only extra affordance is a JSON body; no bypass of any protection.
+ */
+ async postJson(url: string, payload: unknown): Promise<RawPage> {
+ const parsed = new URL(url);
+ if (parsed.pathname !== "/robots.txt") {
+ const rules = await loadRobots(parsed.origin);
+ if (!robotsAllows(rules, parsed.pathname + parsed.search)) {
+ throw new DisallowedError(url);
+ }
+ }
+
+ const wait = this.lastRequestAt + this.minIntervalMs - Date.now();
+ if (wait > 0) await sleep(wait);
+ this.lastRequestAt = Date.now();
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "User-Agent": USER_AGENT,
+ "Content-Type": "application/json",
+ Accept: "application/json,*/*;q=0.8",
+ },
+ body: JSON.stringify(payload),
+ redirect: "follow",
+ signal: AbortSignal.timeout(25_000),
+ });
+
+ if (response.status === 401 || response.status === 403 || response.status === 429) {
+ throw new BlockedError(url, response.status);
+ }
+ if (!response.ok) {
+ throw new Error(`POST ${url} → HTTP ${response.status}`);
+ }
+
+ const body = Buffer.from(await response.arrayBuffer());
+ return {
+ url,
+ retrievedAt: new Date().toISOString(),
+ contentType: response.headers.get("content-type") ?? "application/json",
+ body,
+ contentHash: sha256(body),
+ };
+ }
}
export class BlockedError extends Error {
diff --git a/collectors/meritage-homes/package.json b/collectors/meritage-homes/package.json
new file mode 100644
index 00000000..2eac025f
--- /dev/null
+++ b/collectors/meritage-homes/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@homesonspec/collector-meritage-homes",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": { "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" },
+ "dependencies": {
+ "@homesonspec/collectors-common": "workspace:*",
+ "@homesonspec/schemas": "workspace:*",
+ "@homesonspec/shared": "workspace:*"
+ },
+ "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/collectors/meritage-homes/src/index.ts b/collectors/meritage-homes/src/index.ts
new file mode 100644
index 00000000..aecf5d35
--- /dev/null
+++ b/collectors/meritage-homes/src/index.ts
@@ -0,0 +1,323 @@
+import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
+import { normalizeStateCode } from "@homesonspec/shared";
+import {
+ fetchFixtures,
+ LiveFetcher,
+ type ExtractionOutput,
+ type FetchContext,
+ type RawPage,
+ type SourceAdapter,
+} from "@homesonspec/collectors-common";
+
+/**
+ * Meritage Homes adapter — JSON-API source (recon 2026-07-28).
+ *
+ * meritagehomes.com is a Next.js + Sitecore-headless SPA. Metro/community pages
+ * are server-rendered chrome only; homes are client-injected via Sitecore Search
+ * ("Discover"). The inventory feed is a single POST that returns a NATIONAL,
+ * paginated home list:
+ *
+ * POST https://discover.sitecorecloud.io/discover/v2/173266879
+ * Content-Type: application/json
+ * body: { context, widget: { items: [{ rfk_id:"rfkid_503",
+ * search:{ content:{}, facet:{all:true}, offset, limit,
+ * sort:{value:[{name:"title_descending"}]},
+ * filter:{ type:"and", filters:[ {status ∈ [Available,Inventory]}
+ * (+ optional {state}) ] } },
+ * entity:"home", sources:["xm_cloud_public_website"] }] } }
+ *
+ * The response widget (type:"content_grid", entity:"home") carries
+ * { total_item, limit, offset, content:[ home… ] }
+ * where each home has address/city/state/zipcode/price/bedrooms/full_bathrooms/
+ * half_bathrooms/sqft/stories/garages/location("lat, lon")/floorplan_name/
+ * community_sheet_name/construction_stage/completion_estimated/url/id.
+ *
+ * Verified: the `authorization` header is OPTIONAL (200 without it); the customer
+ * id (173266879) in the path is the only identifier. Removing the geo/metro
+ * filters yields the whole company inventory (total_item ≈ 2529 across 11 states).
+ * robots.txt allows / (meritagehomes.com); the discover host serves no robots.txt
+ * (fail-open). Facts-only: image_urls are intentionally dropped (mediaRights=NONE).
+ *
+ * Batch control: MERITAGE_PAGE_LIMIT (pages of `limit` homes, default 10)
+ * Optional state: MERITAGE_STATE (2-letter, e.g. TX — server-side `state` filter)
+ */
+
+const DISCOVER_URL = "https://discover.sitecorecloud.io/discover/v2/173266879";
+const BUILDER_SLUG = "meritage-homes";
+const PAGE_SIZE = 100; // server max per content_grid page
+const PAGE_LIMIT = Number(process.env.MERITAGE_PAGE_LIMIT ?? 10);
+const STATE_FILTER = (process.env.MERITAGE_STATE ?? "").trim().toUpperCase() || null;
+// The synthesized normalized-page URL carries the offset so snapshots stay distinct.
+const PAGE_URL = (offset: number) => `${DISCOVER_URL}#content_grid&offset=${offset}`;
+
+function fv<T>(value: T | null, raw: string | null, sourceUrl: string, evidenceText?: string | null): FieldValue<T> {
+ return { value, raw, evidenceText: evidenceText ?? raw, sourceUrl, confidence: value === null ? 0 : 1 };
+}
+
+// A positive finite number, or null. Never guesses; 0 / negative / NaN → null.
+const posNum = (v: unknown): number | null => {
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+ return Number.isFinite(n) && n > 0 ? n : null;
+};
+// A non-negative number (baths/garages may legitimately be 0-ish), or null.
+const nonNegNum = (v: unknown): number | null => {
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+ return Number.isFinite(n) && n >= 0 ? n : null;
+};
+const str = (v: unknown): string | null => {
+ if (v == null) return null;
+ const s = String(v).trim();
+ return s ? s : null;
+};
+
+interface DiscoverHome {
+ id?: string;
+ address?: string;
+ city?: string;
+ state?: string;
+ zipcode?: string | number;
+ price?: number | string;
+ bedrooms?: number;
+ full_bathrooms?: number;
+ half_bathrooms?: number;
+ sqft?: number;
+ stories?: number;
+ garages?: number;
+ location?: string; // "lat, lon"
+ floorplan_name?: string;
+ community_sheet_name?: string;
+ community_id?: string;
+ status?: string;
+ construction_stage?: string;
+ completion_estimated?: number; // unix seconds
+ url?: string;
+}
+
+/** Build the content_grid POST body for a given offset. */
+function buildBody(offset: number): unknown {
+ const filters: unknown[] = [
+ { type: "anyOf", name: "status", values: ["Available", "Inventory"] },
+ ];
+ if (STATE_FILTER) filters.push({ type: "anyOf", name: "state", values: [STATE_FILTER] });
+ return {
+ context: { page: { uri: "/search" } },
+ widget: {
+ items: [
+ {
+ rfk_id: "rfkid_503",
+ search: {
+ content: {},
+ facet: { all: false },
+ offset,
+ limit: PAGE_SIZE,
+ sort: { value: [{ name: "title_descending" }] },
+ filter: { type: "and", filters },
+ },
+ entity: "home",
+ sources: ["xm_cloud_public_website"],
+ },
+ ],
+ },
+ };
+}
+
+/** Pull the content_grid widget (entity:"home") out of a Discover response body. */
+function gridWidget(body: string): { total: number; offset: number; content: DiscoverHome[] } | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return null;
+ }
+ const widgets = (parsed as { widgets?: unknown[] })?.widgets;
+ if (!Array.isArray(widgets)) return null;
+ const w = widgets.find(
+ (x) => (x as { type?: string; entity?: string })?.type === "content_grid" &&
+ (x as { entity?: string })?.entity === "home",
+ ) as { total_item?: number; offset?: number; content?: DiscoverHome[] } | undefined;
+ if (!w) return null;
+ return {
+ total: Number(w.total_item ?? 0),
+ offset: Number(w.offset ?? 0),
+ content: Array.isArray(w.content) ? w.content : [],
+ };
+}
+
+/** "33.507105, -112.440173" → { lat, lon } (US lon is negative — signs preserved). */
+function parseLocation(loc: unknown): { lat: number | null; lon: number | null } {
+ const s = str(loc);
+ if (!s) return { lat: null, lon: null };
+ const m = s.match(/(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/);
+ if (!m) return { lat: null, lon: null };
+ const lat = Number(m[1]);
+ const lon = Number(m[2]);
+ return {
+ lat: Number.isFinite(lat) && lat !== 0 ? lat : null,
+ lon: Number.isFinite(lon) && lon !== 0 ? lon : null,
+ };
+}
+
+/** Meritage stages → our construction-status enum. Inventory homes ("QMI") that
+ * report "Construction Complete" are move-in ready; earlier stages are under
+ * construction. Unrecognized/blank → null (never guessed). */
+function constructionStatus(stage: unknown): "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+ const s = (str(stage) ?? "").toLowerCase();
+ if (!s) return null;
+ // "Construction Complete" / "Warranty Handoff" = finished & ready; all other
+ // named stages (slab, framing, cabinets, countertops, flooring…) are in progress.
+ if (s.includes("construction complete") || s.includes("warranty")) return "MOVE_IN_READY";
+ return "UNDER_CONSTRUCTION";
+}
+
+const zip5 = (v: unknown): string | null => {
+ const s = str(v);
+ if (!s) return null;
+ const m = s.match(/\b(\d{5})\b/);
+ return m ? m[1]! : null;
+};
+
+/** completion_estimated is unix time, but the feed is inconsistent: most rows are
+ * SECONDS (10 digits) while some are MILLISECONDS (13 digits). Normalize by
+ * magnitude, then clamp to a sane window so a bogus timestamp never reaches the
+ * publisher's `new Date()` (which overflowed to year 57626 on a raw ms value). */
+const isoFromUnix = (v: unknown): string | null => {
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return null;
+ const ms = v > 1e12 ? v : v * 1000; // ≥1e12 ≈ ms since epoch; otherwise seconds
+ const d = new Date(ms);
+ if (Number.isNaN(d.getTime())) return null;
+ const year = d.getUTCFullYear();
+ if (year < 2000 || year > 2100) return null; // out of plausible range → don't guess
+ return d.toISOString().slice(0, 10);
+};
+
+export const meritageHomesAdapter: SourceAdapter = {
+ key: "meritage-homes-site",
+ version: "1.0.0",
+
+ async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
+ if (ctx.mode === "fixture") {
+ yield* fetchFixtures(ctx);
+ return;
+ }
+ const fetcher = new LiveFetcher(ctx.registry);
+ let offset = 0;
+ for (let pageNo = 0; pageNo < Math.max(1, PAGE_LIMIT); pageNo++) {
+ let page: RawPage;
+ try {
+ page = await fetcher.postJson(PAGE_URL(offset), buildBody(offset));
+ } catch (error) {
+ console.warn(` meritage offset=${offset}: ${error instanceof Error ? error.message : String(error)}`);
+ return; // a block/403 stops collection (source marked degraded upstream)
+ }
+ const grid = gridWidget(page.body.toString("utf8"));
+ yield page;
+ const got = grid?.content.length ?? 0;
+ offset += PAGE_SIZE;
+ // Stop when the server returns fewer than a full page or we've covered total_item.
+ if (!grid || got < PAGE_SIZE || (grid.total > 0 && offset >= grid.total)) return;
+ }
+ },
+
+ extract(page: RawPage): ExtractionOutput {
+ try {
+ const grid = gridWidget(page.body.toString("utf8"));
+ if (!grid) {
+ return { records: [], errors: [{ url: page.url, reason: "no content_grid home widget in Discover response" }] };
+ }
+ const records: ExtractedRecord[] = [];
+ const errors: { url: string; reason: string }[] = [];
+
+ for (const h of grid.content) {
+ const state = normalizeStateCode(str(h.state));
+ const city = str(h.city);
+ const zip = zip5(h.zipcode);
+ const address = str(h.address);
+ const community = str(h.community_sheet_name);
+ const plan = str(h.floorplan_name);
+ const { lat, lon } = parseLocation(h.location);
+ const price = posNum(h.price);
+ const beds = posNum(h.bedrooms);
+ const full = nonNegNum(h.full_bathrooms);
+ const half = nonNegNum(h.half_bathrooms);
+ const bathsTotal = full === null ? null : full + (half ?? 0) * 0.5;
+ const sqft = posNum(h.sqft);
+ const stories = posNum(h.stories);
+ const garages = nonNegNum(h.garages);
+ const homeId = str(h.id);
+ const url = str(h.url) ?? page.url;
+ const cStatus = constructionStatus(h.construction_stage);
+ const estCompletion = isoFromUnix(h.completion_estimated);
+
+ if (!address) {
+ errors.push({ url, reason: `home ${homeId ?? "?"} missing address — skipped` });
+ continue;
+ }
+ // The publisher requires an inventory home to hang off a community (FK).
+ // A home the feed leaves community-less can't be published — skip it and
+ // log it honestly rather than stage a record that will crash at publish.
+ if (!community) {
+ errors.push({ url, reason: `home ${address} has no community_sheet_name — cannot attach to a community, skipped` });
+ continue;
+ }
+
+ // Community FIRST — publish creates the FK target the home record needs.
+ records.push({
+ entityType: "community",
+ canonicalHints: { builderSlug: BUILDER_SLUG, communityName: community },
+ fields: {
+ name: fv(community, community, url, "Discover community_sheet_name"),
+ street: fv<string>(null, null, url),
+ city: fv(city, city, url),
+ state: fv(state, str(h.state), url),
+ zip: fv(zip, str(h.zipcode), url),
+ county: fv<string>(null, null, url),
+ metro: fv<string>(null, null, url),
+ lat: fv(lat, null, url),
+ lon: fv(lon, null, url),
+ hoaFeeMonthly: fv<number>(null, null, url),
+ schoolDistrict: fv<string>(null, null, url),
+ ageRestricted: fv<boolean>(null, null, url),
+ },
+ });
+
+ records.push({
+ entityType: "inventory_home",
+ canonicalHints: {
+ builderSlug: BUILDER_SLUG,
+ communityName: community,
+ address,
+ builderInventoryId: homeId ?? undefined,
+ lat: lat ?? undefined,
+ lon: lon ?? undefined,
+ planName: plan ?? undefined,
+ },
+ fields: {
+ street: fv(address, address, url, "Discover home address"),
+ city: fv(city, city, url),
+ state: fv(state, str(h.state), url),
+ zip: fv(zip, str(h.zipcode), url),
+ price: fv(price, price === null ? null : String(h.price), url, price === null ? null : `Discover price ${h.price}`),
+ beds: fv(beds, beds === null ? null : String(h.bedrooms), url),
+ bathsTotal: fv(bathsTotal, bathsTotal === null ? null : String(bathsTotal), url, bathsTotal === null ? null : `${full} full + ${half ?? 0} half`),
+ sqft: fv(sqft, sqft === null ? null : String(h.sqft), url),
+ stories: fv(stories, stories === null ? null : String(h.stories), url),
+ garageSpaces: fv(garages, garages === null ? null : String(h.garages), url),
+ homeType: fv("SINGLE_FAMILY" as const, null, url, "Meritage single-family inventory home"),
+ constructionStatus: fv(cStatus, str(h.construction_stage), url, cStatus === null ? null : `stage: ${str(h.construction_stage)}`),
+ estCompletionDate: fv(estCompletion, estCompletion, url),
+ lotNumber: fv<string>(null, null, url),
+ builderInventoryId: fv(homeId, homeId, url),
+ lat: fv(lat, null, url, lat === null ? null : "Discover location"),
+ lon: fv(lon, null, url, lon === null ? null : "Discover location"),
+ planName: fv(plan, plan, url),
+ // facts-only: image_urls exist in the feed but are intentionally dropped.
+ images: fv<string[]>([], null, url),
+ },
+ });
+ }
+ return { records, errors };
+ } catch (error) {
+ return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+ }
+ },
+};
diff --git a/collectors/meritage-homes/tsconfig.json b/collectors/meritage-homes/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/meritage-homes/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 586d2be1..209f25c3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -154,6 +154,9 @@ importers:
'@homesonspec/collector-dr-horton':
specifier: workspace:*
version: link:../../collectors/dr-horton
+ '@homesonspec/collector-dream-finders':
+ specifier: workspace:*
+ version: link:../../collectors/dream-finders
'@homesonspec/collector-kb-home':
specifier: workspace:*
version: link:../../collectors/kb-home
@@ -163,6 +166,9 @@ importers:
'@homesonspec/collector-meridian-homes':
specifier: workspace:*
version: link:../../collectors/meridian-homes
+ '@homesonspec/collector-meritage-homes':
+ specifier: workspace:*
+ version: link:../../collectors/meritage-homes
'@homesonspec/collector-pulte':
specifier: workspace:*
version: link:../../collectors/pulte
@@ -411,6 +417,28 @@ importers:
specifier: ^4.0.0
version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+ collectors/meritage-homes:
+ dependencies:
+ '@homesonspec/collectors-common':
+ specifier: workspace:*
+ version: link:../common
+ '@homesonspec/schemas':
+ specifier: workspace:*
+ version: link:../../packages/schemas
+ '@homesonspec/shared':
+ specifier: workspace:*
+ version: link:../../packages/shared
+ devDependencies:
+ '@types/node':
+ specifier: ^22.10.5
+ version: 22.20.1
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.0
+ version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+
collectors/pulte:
dependencies:
'@homesonspec/collectors-common':
← c4b894e7 storefront: enable home images (BUILDER_IMAGES_ENABLED=1) —
·
back to Homesonspec
·
build-loop: add Meritage (national Sitecore-Discover feed) t 6515c4d4 →