create-contractor-site 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-contractor-site.mjs +293 -0
- package/package.json +42 -0
- package/scripts/smoke-test.mjs +502 -0
- package/src/copy-template.mjs +286 -0
- package/src/prompts.mjs +121 -0
- package/src/replace-data.mjs +465 -0
- package/src/run-command.mjs +399 -0
- package/src/validate-target.mjs +96 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('./prompts.mjs').ScaffoldAnswers} ScaffoldAnswers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} value
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function slugify(value) {
|
|
13
|
+
return (
|
|
14
|
+
String(value)
|
|
15
|
+
.normalize('NFKD')
|
|
16
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
19
|
+
.replace(/^-+|-+$/g, '')
|
|
20
|
+
.replace(/-{2,}/g, '-') || 'service'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deduplicate primary service inputs by display name and by slug.
|
|
26
|
+
* Keeps the first occurrence; later duplicates/extras that collide are dropped.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} values
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function normalizePrimaryServices(values) {
|
|
32
|
+
/** @type {string[]} */
|
|
33
|
+
const result = [];
|
|
34
|
+
const usedNames = new Set();
|
|
35
|
+
const usedSlugs = new Set();
|
|
36
|
+
|
|
37
|
+
for (const raw of values || []) {
|
|
38
|
+
const name = String(raw || '').trim();
|
|
39
|
+
if (!name) continue;
|
|
40
|
+
|
|
41
|
+
const nameKey = name.toLowerCase();
|
|
42
|
+
const slug = slugify(name);
|
|
43
|
+
|
|
44
|
+
if (usedNames.has(nameKey) || usedSlugs.has(slug)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
usedNames.add(nameKey);
|
|
49
|
+
usedSlugs.add(slug);
|
|
50
|
+
result.push(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Keep generated service display names unique.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} value
|
|
60
|
+
* @param {Set<string>} used
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function uniqueServiceName(value, used) {
|
|
64
|
+
const base = String(value || 'Service').trim() || 'Service';
|
|
65
|
+
let candidate = base;
|
|
66
|
+
let suffix = 2;
|
|
67
|
+
|
|
68
|
+
if (used.has(candidate.toLowerCase())) {
|
|
69
|
+
candidate = `${base} Services`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
while (used.has(candidate.toLowerCase())) {
|
|
73
|
+
candidate = `${base} Services ${suffix}`;
|
|
74
|
+
suffix += 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
used.add(candidate.toLowerCase());
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Keep service slugs unique (for appended rows only; template slugs stay stable
|
|
83
|
+
* unless an append would collide).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} value
|
|
86
|
+
* @param {Set<string>} used
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function uniqueServiceSlug(value, used) {
|
|
90
|
+
const base = slugify(value);
|
|
91
|
+
let candidate = base;
|
|
92
|
+
let suffix = 2;
|
|
93
|
+
|
|
94
|
+
while (used.has(candidate)) {
|
|
95
|
+
candidate = `${base}-${suffix}`;
|
|
96
|
+
suffix += 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
used.add(candidate);
|
|
100
|
+
return candidate;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a free-text service area string into city/area display names.
|
|
105
|
+
* Splits on commas and " and " connectors (e.g. "A, B, C and D").
|
|
106
|
+
*
|
|
107
|
+
* @param {string} serviceArea
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
export function parseServiceAreaNames(serviceArea) {
|
|
111
|
+
return String(serviceArea || '')
|
|
112
|
+
.split(/[,;\/\n&]+/)
|
|
113
|
+
.flatMap((part) => part.split(/\s+and\s+/i))
|
|
114
|
+
.map((part) => part.trim())
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a unique ordered list of service-area names.
|
|
120
|
+
* - `city` is always first when provided
|
|
121
|
+
* - remaining names come from parsing `serviceArea`
|
|
122
|
+
* - deduped by display name and slug (first wins)
|
|
123
|
+
*
|
|
124
|
+
* @param {string} city
|
|
125
|
+
* @param {string} serviceArea
|
|
126
|
+
* @returns {string[]}
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeServiceAreaNames(city, serviceArea) {
|
|
129
|
+
/** @type {string[]} */
|
|
130
|
+
const raw = [];
|
|
131
|
+
const primary = String(city || '').trim();
|
|
132
|
+
if (primary) raw.push(primary);
|
|
133
|
+
raw.push(...parseServiceAreaNames(serviceArea));
|
|
134
|
+
return normalizePrimaryServices(raw);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rebuild areas.areas rows from normalized city/area names.
|
|
139
|
+
* - Reuses existing row shapes when name/slug matches
|
|
140
|
+
* - Guarantees unique slugs
|
|
141
|
+
* - Primary city (index 0) gets answers.zip when provided
|
|
142
|
+
*
|
|
143
|
+
* @param {{ name?: string, slug?: string, county?: string, state?: string, zip_codes?: string[], [key: string]: unknown }[]} existingAreas
|
|
144
|
+
* @param {string[]} areaNames
|
|
145
|
+
* @param {{ city: string, state: string, zip?: string }} ctx
|
|
146
|
+
* @returns {Record<string, unknown>[]}
|
|
147
|
+
*/
|
|
148
|
+
export function buildAlignedAreas(existingAreas, areaNames, ctx) {
|
|
149
|
+
const normalized =
|
|
150
|
+
areaNames.length > 0
|
|
151
|
+
? normalizePrimaryServices(areaNames)
|
|
152
|
+
: normalizePrimaryServices([ctx.city].filter(Boolean));
|
|
153
|
+
|
|
154
|
+
const existing = Array.isArray(existingAreas) ? [...existingAreas] : [];
|
|
155
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
156
|
+
const byName = new Map();
|
|
157
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
158
|
+
const bySlug = new Map();
|
|
159
|
+
|
|
160
|
+
for (const row of existing) {
|
|
161
|
+
if (!row || typeof row !== 'object') continue;
|
|
162
|
+
const nameKey = String(row.name || '')
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase();
|
|
165
|
+
const slugKey = String(row.slug || slugify(String(row.name || ''))).toLowerCase();
|
|
166
|
+
if (nameKey && !byName.has(nameKey)) byName.set(nameKey, row);
|
|
167
|
+
if (slugKey && !bySlug.has(slugKey)) bySlug.set(slugKey, row);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const templateRow = existing[existing.length - 1] ?? {
|
|
171
|
+
county: '',
|
|
172
|
+
state: ctx.state,
|
|
173
|
+
zip_codes: [],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const sourceNames =
|
|
177
|
+
normalized.length > 0
|
|
178
|
+
? normalized
|
|
179
|
+
: existing.map((row) => String(row.name || 'Area')).filter(Boolean);
|
|
180
|
+
|
|
181
|
+
const fallbackName = String(ctx.city || 'Service Area').trim() || 'Service Area';
|
|
182
|
+
const names = sourceNames.length > 0 ? sourceNames : [fallbackName];
|
|
183
|
+
|
|
184
|
+
/** @type {Set<string>} */
|
|
185
|
+
const usedSlugs = new Set();
|
|
186
|
+
|
|
187
|
+
return names.map((rawName, index) => {
|
|
188
|
+
const name = String(rawName || fallbackName).trim() || fallbackName;
|
|
189
|
+
const nameKey = name.toLowerCase();
|
|
190
|
+
const preferredSlug = slugify(name);
|
|
191
|
+
const matchedSource = byName.get(nameKey) || bySlug.get(preferredSlug);
|
|
192
|
+
const source = matchedSource || templateRow;
|
|
193
|
+
|
|
194
|
+
const slug = uniqueServiceSlug(name, usedSlugs);
|
|
195
|
+
const isPrimary = index === 0;
|
|
196
|
+
|
|
197
|
+
/** @type {Record<string, unknown>} */
|
|
198
|
+
const row = {
|
|
199
|
+
...source,
|
|
200
|
+
name,
|
|
201
|
+
slug,
|
|
202
|
+
state: ctx.state || source.state || '',
|
|
203
|
+
county: matchedSource?.county || name,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (isPrimary && ctx.zip) {
|
|
207
|
+
row.zip_codes = [ctx.zip];
|
|
208
|
+
} else if (matchedSource && Array.isArray(matchedSource.zip_codes)) {
|
|
209
|
+
row.zip_codes = matchedSource.zip_codes;
|
|
210
|
+
} else {
|
|
211
|
+
row.zip_codes = [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return row;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build aligned service rows for business.services_offered and services.json.
|
|
220
|
+
* - Generates exactly the normalized client service list
|
|
221
|
+
* - Dedupes input by name + slug before applying
|
|
222
|
+
* - Ensures final display names and slugs are unique
|
|
223
|
+
* - Reuses template row shape/defaults while allowing client-specific array length
|
|
224
|
+
*
|
|
225
|
+
* @param {{ name?: string, slug?: string, id?: string, [key: string]: unknown }[]} existingServices
|
|
226
|
+
* @param {string[]} primaryServices
|
|
227
|
+
* @param {{ businessName: string, serviceArea: string }} ctx
|
|
228
|
+
* @returns {{
|
|
229
|
+
* services: Record<string, unknown>[],
|
|
230
|
+
* offered: { name: string, slug: string }[],
|
|
231
|
+
* }}
|
|
232
|
+
*/
|
|
233
|
+
export function buildAlignedServices(existingServices, primaryServices, ctx) {
|
|
234
|
+
const normalized = normalizePrimaryServices(primaryServices);
|
|
235
|
+
const existing = Array.isArray(existingServices) ? [...existingServices] : [];
|
|
236
|
+
|
|
237
|
+
const templateRow = existing[existing.length - 1] ?? {
|
|
238
|
+
short_description: 'Professional contractor services.',
|
|
239
|
+
full_description: 'Professional contractor services.',
|
|
240
|
+
image: './images/services/masonry.jpg',
|
|
241
|
+
icon: 'hammer',
|
|
242
|
+
highlights: ['Quality work'],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const sourceNames = normalized.length > 0 ? normalized : existing.map((row) => String(row.name || 'Service'));
|
|
246
|
+
|
|
247
|
+
/** @type {Record<string, unknown>[]} */
|
|
248
|
+
const rows = sourceNames.map((rawName, index) => {
|
|
249
|
+
const source = existing[index] || templateRow;
|
|
250
|
+
const name = String(rawName || source.name || 'Service');
|
|
251
|
+
return {
|
|
252
|
+
...source,
|
|
253
|
+
name,
|
|
254
|
+
short_description: `${name} services tailored to your project.`,
|
|
255
|
+
full_description: `Professional ${name.toLowerCase()} services from ${ctx.businessName} serving ${ctx.serviceArea}.`,
|
|
256
|
+
highlights: Array.isArray(source.highlights) && source.highlights.length > 0
|
|
257
|
+
? source.highlights
|
|
258
|
+
: [name],
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Uniquify display names across the final list.
|
|
263
|
+
const usedNames = new Set();
|
|
264
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
265
|
+
const prevName = String(rows[i].name || 'Service');
|
|
266
|
+
const name = uniqueServiceName(prevName, usedNames);
|
|
267
|
+
if (name !== prevName) {
|
|
268
|
+
rows[i] = {
|
|
269
|
+
...rows[i],
|
|
270
|
+
name,
|
|
271
|
+
short_description: `${name} services tailored to your project.`,
|
|
272
|
+
full_description: `Professional ${name.toLowerCase()} services from ${ctx.businessName} serving ${ctx.serviceArea}.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Slugs follow final service names so generated routes, navigation and landings align.
|
|
278
|
+
const usedSlugs = new Set();
|
|
279
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
280
|
+
const slug = uniqueServiceSlug(String(rows[i].name || 'service'), usedSlugs);
|
|
281
|
+
|
|
282
|
+
rows[i] = {
|
|
283
|
+
...rows[i],
|
|
284
|
+
slug,
|
|
285
|
+
id: slug,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const offered = rows.map((row) => ({
|
|
290
|
+
name: String(row.name),
|
|
291
|
+
slug: String(row.slug),
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
return { services: rows, offered };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Read JSON, apply mutator, write back with stable formatting.
|
|
299
|
+
* Never touches the source template — caller must pass target paths only.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} filePath
|
|
302
|
+
* @param {(data: any) => void} mutator
|
|
303
|
+
*/
|
|
304
|
+
function updateJsonFile(filePath, mutator) {
|
|
305
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
306
|
+
const data = JSON.parse(raw);
|
|
307
|
+
mutator(data);
|
|
308
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {{ name: string, slug: string }[]} services
|
|
313
|
+
* @returns {{ label: string, href: string }[]}
|
|
314
|
+
*/
|
|
315
|
+
function serviceLinks(services) {
|
|
316
|
+
return services.map((service) => ({
|
|
317
|
+
label: service.name,
|
|
318
|
+
href: `/services/${service.slug}`,
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {string} value
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function lowerFirst(value) {
|
|
327
|
+
const text = String(value || '').trim();
|
|
328
|
+
if (!text) return 'service';
|
|
329
|
+
return `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Value-only replacement in target `src/data/*.json`.
|
|
334
|
+
* Preserves keys, array lengths/shape (except additive service rows), variants, and `_instructions`.
|
|
335
|
+
*
|
|
336
|
+
* Service names/slugs stay aligned between business.services_offered and services.json.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} targetDir
|
|
339
|
+
* @param {ScaffoldAnswers} answers
|
|
340
|
+
*/
|
|
341
|
+
export function replaceTargetData(targetDir, answers) {
|
|
342
|
+
const dataDir = path.join(targetDir, 'src', 'data');
|
|
343
|
+
const fullAddress = `${answers.street}, ${answers.city}, ${answers.state} ${answers.zip}`;
|
|
344
|
+
const normalizedServices = normalizePrimaryServices(answers.primaryServices);
|
|
345
|
+
const typeOfServices = normalizedServices.join(', ');
|
|
346
|
+
|
|
347
|
+
/** @type {{ services: Record<string, unknown>[], offered: { name: string, slug: string }[] } | null} */
|
|
348
|
+
let aligned = null;
|
|
349
|
+
|
|
350
|
+
updateJsonFile(path.join(dataDir, 'services.json'), (servicesData) => {
|
|
351
|
+
const existing = Array.isArray(servicesData.services)
|
|
352
|
+
? servicesData.services
|
|
353
|
+
: [];
|
|
354
|
+
aligned = buildAlignedServices(existing, normalizedServices, {
|
|
355
|
+
businessName: answers.businessName,
|
|
356
|
+
serviceArea: answers.serviceArea,
|
|
357
|
+
});
|
|
358
|
+
servicesData.services = aligned.services;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
updateJsonFile(path.join(dataDir, 'business.json'), (business) => {
|
|
362
|
+
business.name = answers.businessName;
|
|
363
|
+
business.legal_name = answers.legalName;
|
|
364
|
+
business.tagline = answers.tagline;
|
|
365
|
+
business.phones = [answers.phone];
|
|
366
|
+
business.emails = [answers.email];
|
|
367
|
+
business.address = {
|
|
368
|
+
...business.address,
|
|
369
|
+
street: answers.street,
|
|
370
|
+
city: answers.city,
|
|
371
|
+
state: answers.state,
|
|
372
|
+
zip: answers.zip,
|
|
373
|
+
full: fullAddress,
|
|
374
|
+
};
|
|
375
|
+
business.service_area = answers.serviceArea;
|
|
376
|
+
business.founded_year = answers.foundedYear;
|
|
377
|
+
business.type_of_services = typeOfServices;
|
|
378
|
+
|
|
379
|
+
if (aligned) {
|
|
380
|
+
business.services_offered = aligned.offered.map((row) => ({ ...row }));
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
updateJsonFile(path.join(dataDir, 'site.json'), (site) => {
|
|
385
|
+
if (answers.siteUrl) {
|
|
386
|
+
site.url = answers.siteUrl.replace(/\/$/, '');
|
|
387
|
+
}
|
|
388
|
+
site.logo = {
|
|
389
|
+
...site.logo,
|
|
390
|
+
alt: answers.businessName,
|
|
391
|
+
};
|
|
392
|
+
site.seo = {
|
|
393
|
+
...site.seo,
|
|
394
|
+
default_title: `${answers.businessName} | ${answers.city}, ${answers.state}`,
|
|
395
|
+
default_description: `${answers.tagline} Serving ${answers.serviceArea}.`,
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
updateJsonFile(path.join(dataDir, 'areas.json'), (areas) => {
|
|
400
|
+
// Preserve _instructions, variant, section_title, and any other top-level keys.
|
|
401
|
+
areas.primary_city = answers.city;
|
|
402
|
+
areas.service_radius = `Within the ${answers.serviceArea} area`;
|
|
403
|
+
areas.section_subtitle = `Proudly serving homeowners and businesses in ${answers.serviceArea}.`;
|
|
404
|
+
|
|
405
|
+
const areaNames = normalizeServiceAreaNames(answers.city, answers.serviceArea);
|
|
406
|
+
const existing = Array.isArray(areas.areas) ? areas.areas : [];
|
|
407
|
+
areas.areas = buildAlignedAreas(existing, areaNames, {
|
|
408
|
+
city: answers.city,
|
|
409
|
+
state: answers.state,
|
|
410
|
+
zip: answers.zip,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (aligned) {
|
|
415
|
+
updateJsonFile(path.join(dataDir, 'navigation.json'), (navigation) => {
|
|
416
|
+
const links = serviceLinks(aligned.offered);
|
|
417
|
+
|
|
418
|
+
if (Array.isArray(navigation.header)) {
|
|
419
|
+
const servicesItem = navigation.header.find((item) => item?.href === '/services');
|
|
420
|
+
if (servicesItem) {
|
|
421
|
+
servicesItem.children = links.map((link) => ({ ...link }));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (Array.isArray(navigation.footer)) {
|
|
426
|
+
const servicesColumn = navigation.footer.find((column) => column?.title === 'Services');
|
|
427
|
+
if (servicesColumn) {
|
|
428
|
+
servicesColumn.links = links.map((link) => ({ ...link }));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
updateJsonFile(path.join(dataDir, 'landings.json'), (landings) => {
|
|
434
|
+
const existing = Array.isArray(landings.landing_pages)
|
|
435
|
+
? landings.landing_pages
|
|
436
|
+
: [];
|
|
437
|
+
|
|
438
|
+
landings.landing_pages = existing
|
|
439
|
+
.slice(0, aligned.offered.length)
|
|
440
|
+
.map((page, index) => {
|
|
441
|
+
const service = aligned.offered[index];
|
|
442
|
+
if (!service) return page;
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
...page,
|
|
446
|
+
name: service.name,
|
|
447
|
+
slug: service.slug,
|
|
448
|
+
service_id: service.slug,
|
|
449
|
+
meta_title: `${service.name} Contractor in ${answers.city}, ${answers.state} | ${answers.businessName}`,
|
|
450
|
+
meta_description: `Professional ${lowerFirst(service.name)} services in ${answers.serviceArea}.`,
|
|
451
|
+
hero: {
|
|
452
|
+
...page.hero,
|
|
453
|
+
headline: `${service.name} Services Built Around Your Project`,
|
|
454
|
+
paragraph: `Get professional ${lowerFirst(service.name)} services from ${answers.businessName} serving ${answers.serviceArea}.`,
|
|
455
|
+
image_alt: `${service.name} project by ${answers.businessName}`,
|
|
456
|
+
},
|
|
457
|
+
cta: {
|
|
458
|
+
...page.cta,
|
|
459
|
+
headline: `Ready for a free ${lowerFirst(service.name)} estimate?`,
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|