create-contractor-site 2.3.0 → 2.3.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/README.md +145 -146
- package/bin/create-contractor-site.mjs +321 -321
- package/package.json +1 -1
- package/scripts/smoke-test.mjs +1819 -1756
- package/src/copy-template.mjs +296 -296
- package/src/prompts.mjs +461 -461
- package/src/replace-data.mjs +504 -504
- package/src/run-command.mjs +399 -399
- package/src/validate-target.mjs +96 -96
package/src/prompts.mjs
CHANGED
|
@@ -1,461 +1,461 @@
|
|
|
1
|
-
import * as readline from 'node:readline/promises';
|
|
2
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
-
|
|
4
|
-
/** Default free-estimate CTA when blank/omitted. */
|
|
5
|
-
export const DEFAULT_FREE_ESTIMATE = 'Free On-Site Estimate';
|
|
6
|
-
/** Default years-of-experience text when blank/omitted. */
|
|
7
|
-
export const DEFAULT_YEARS_EXPERIENCE = '10+';
|
|
8
|
-
/** Default license statement when blank/omitted. */
|
|
9
|
-
export const DEFAULT_LICENSE = 'Licensed & Insured';
|
|
10
|
-
/** Default insurance statement when blank/omitted. */
|
|
11
|
-
export const DEFAULT_INSURANCE =
|
|
12
|
-
"Fully insured with general liability and workers' compensation.";
|
|
13
|
-
/** Default payment methods when blank/omitted/empty. */
|
|
14
|
-
export const DEFAULT_PAYMENT_METHODS = [
|
|
15
|
-
'Cash',
|
|
16
|
-
'Check',
|
|
17
|
-
'Credit Card',
|
|
18
|
-
'Financing Available',
|
|
19
|
-
];
|
|
20
|
-
/** Default 3-row business hours (Mon–Fri / Sat / Sun). */
|
|
21
|
-
export const DEFAULT_HOURS = [
|
|
22
|
-
{ days: 'Monday - Friday', time: '7:00 AM - 6:00 PM' },
|
|
23
|
-
{ days: 'Saturday', time: '8:00 AM - 2:00 PM' },
|
|
24
|
-
{ days: 'Sunday', time: 'Closed' },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
/** Canonical `site.json.site_type` values written by new scaffolds. */
|
|
28
|
-
export const SITE_TYPES = /** @type {const} */ (['one-page', 'multipage', 'seo']);
|
|
29
|
-
/** Default website type for NEW CLI scaffolds (all answer paths). */
|
|
30
|
-
export const DEFAULT_SITE_TYPE = /** @type {const} */ ('multipage');
|
|
31
|
-
|
|
32
|
-
/** Allowed `business.social` keys (blank values omitted, never empty strings). */
|
|
33
|
-
export const SOCIAL_NETWORK_KEYS = [
|
|
34
|
-
'facebook', 'instagram', 'youtube', 'tiktok', 'google_business', 'linkedin', 'x',
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* @typedef {'one-page' | 'multipage' | 'seo'} SiteType
|
|
39
|
-
* @typedef {{ days: string, time: string }} BusinessHourRow
|
|
40
|
-
* @typedef {{ name: string, url: string, initials?: string, icon?: string, badge_image?: string }} DirectoryRow
|
|
41
|
-
*
|
|
42
|
-
* @typedef {object} ScaffoldAnswers
|
|
43
|
-
* @property {string} businessName
|
|
44
|
-
* @property {string} legalName
|
|
45
|
-
* @property {string} tagline
|
|
46
|
-
* @property {string} phone
|
|
47
|
-
* @property {string} email
|
|
48
|
-
* @property {string} street
|
|
49
|
-
* @property {string} city
|
|
50
|
-
* @property {string} state
|
|
51
|
-
* @property {string} zip
|
|
52
|
-
* @property {string} serviceArea
|
|
53
|
-
* @property {string} foundedYear empty string when skipped/blank
|
|
54
|
-
* @property {string} freeEstimate
|
|
55
|
-
* @property {string} yearsExperience
|
|
56
|
-
* @property {string} license
|
|
57
|
-
* @property {string} insurance
|
|
58
|
-
* @property {string[]} paymentMethods
|
|
59
|
-
* @property {BusinessHourRow[]} hours always length 3
|
|
60
|
-
* @property {Record<string, string>} social non-blank network keys only
|
|
61
|
-
* @property {DirectoryRow[]} directories empty = none (keep template row + disable)
|
|
62
|
-
* @property {boolean} enableDirectories true iff directories.length > 0
|
|
63
|
-
* @property {SiteType} siteType written to site.json.site_type
|
|
64
|
-
* @property {string[]} primaryServices
|
|
65
|
-
* @property {string} [siteUrl]
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Trim and fall back when the value is blank-looking.
|
|
70
|
-
* @param {unknown} value
|
|
71
|
-
* @param {string} fallback
|
|
72
|
-
* @returns {string}
|
|
73
|
-
*/
|
|
74
|
-
export function requiredText(value, fallback) {
|
|
75
|
-
const text = value == null ? '' : String(value).trim();
|
|
76
|
-
return text || fallback;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Optional text field: trim; blank/whitespace → "".
|
|
81
|
-
* @param {unknown} value
|
|
82
|
-
* @returns {string}
|
|
83
|
-
*/
|
|
84
|
-
export function optionalText(value) {
|
|
85
|
-
if (value == null) return '';
|
|
86
|
-
return String(value).trim();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* CSV string or string[] → trimmed non-empty list; blank/empty → defaults (never []).
|
|
91
|
-
* @param {unknown} value
|
|
92
|
-
* @returns {string[]}
|
|
93
|
-
*/
|
|
94
|
-
export function normalizePaymentMethods(value) {
|
|
95
|
-
/** @type {string[]} */
|
|
96
|
-
let items = [];
|
|
97
|
-
if (typeof value === 'string') {
|
|
98
|
-
items = value
|
|
99
|
-
.split(',')
|
|
100
|
-
.map((s) => s.trim())
|
|
101
|
-
.filter(Boolean);
|
|
102
|
-
} else if (Array.isArray(value)) {
|
|
103
|
-
items = value.map((s) => String(s ?? '').trim()).filter(Boolean);
|
|
104
|
-
}
|
|
105
|
-
return items.length > 0 ? items : [...DEFAULT_PAYMENT_METHODS];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Normalize hours to the fixed 3-row `{ days, time }` shape.
|
|
110
|
-
* Accepts full rows, time-only strings, or compact weekday/sat/sun fields via buildAnswers.
|
|
111
|
-
* Blank/missing rows fall back to defaults — never empty array or partial shape.
|
|
112
|
-
* @param {unknown} value
|
|
113
|
-
* @returns {BusinessHourRow[]}
|
|
114
|
-
*/
|
|
115
|
-
export function normalizeHours(value) {
|
|
116
|
-
const defaults = DEFAULT_HOURS.map((row) => ({ days: row.days, time: row.time }));
|
|
117
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
118
|
-
return defaults;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return defaults.map((def, index) => {
|
|
122
|
-
const raw = value[index];
|
|
123
|
-
if (raw == null || raw === '') {
|
|
124
|
-
return { ...def };
|
|
125
|
-
}
|
|
126
|
-
if (typeof raw === 'string') {
|
|
127
|
-
const time = raw.trim();
|
|
128
|
-
return { days: def.days, time: time || def.time };
|
|
129
|
-
}
|
|
130
|
-
if (typeof raw === 'object') {
|
|
131
|
-
const days =
|
|
132
|
-
/** @type {{ days?: unknown, time?: unknown }} */ (raw).days != null
|
|
133
|
-
? String(/** @type {{ days?: unknown }} */ (raw).days).trim()
|
|
134
|
-
: '';
|
|
135
|
-
const time =
|
|
136
|
-
/** @type {{ time?: unknown }} */ (raw).time != null
|
|
137
|
-
? String(/** @type {{ time?: unknown }} */ (raw).time).trim()
|
|
138
|
-
: '';
|
|
139
|
-
return {
|
|
140
|
-
days: days || def.days,
|
|
141
|
-
time: time || def.time,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
return { ...def };
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** @param {string} name @returns {string} */
|
|
149
|
-
export function initialsFromName(name) {
|
|
150
|
-
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
|
151
|
-
if (parts.length === 0) return '?';
|
|
152
|
-
if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase();
|
|
153
|
-
return parts.map((p) => p[0]).join('').slice(0, 3).toUpperCase();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Object map or `network=url` CSV → non-blank keys only. @param {unknown} value */
|
|
157
|
-
export function normalizeSocial(value) {
|
|
158
|
-
/** @type {Record<string, string>} */
|
|
159
|
-
const out = {};
|
|
160
|
-
const allowed = new Set(SOCIAL_NETWORK_KEYS);
|
|
161
|
-
if (value == null || value === '') return out;
|
|
162
|
-
if (typeof value === 'string') {
|
|
163
|
-
for (const part of value.split(',')) {
|
|
164
|
-
const piece = part.trim();
|
|
165
|
-
const eq = piece.indexOf('=');
|
|
166
|
-
if (eq <= 0) continue;
|
|
167
|
-
const key = piece.slice(0, eq).trim().toLowerCase().replace(/-/g, '_');
|
|
168
|
-
const url = piece.slice(eq + 1).trim();
|
|
169
|
-
if (allowed.has(key) && url) out[key] = url;
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
174
|
-
for (const key of SOCIAL_NETWORK_KEYS) {
|
|
175
|
-
const raw = /** @type {Record<string, unknown>} */ (value)[key];
|
|
176
|
-
if (raw == null) continue;
|
|
177
|
-
const url = String(raw).trim();
|
|
178
|
-
if (url) out[key] = url;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Rows or `Name|url` CSV. Empty → [] (replace keeps ≥1 template row). @param {unknown} value */
|
|
185
|
-
export function normalizeDirectories(value) {
|
|
186
|
-
/** @type {DirectoryRow[]} */
|
|
187
|
-
const rows = [];
|
|
188
|
-
if (value == null || value === '') return rows;
|
|
189
|
-
if (typeof value === 'string') {
|
|
190
|
-
for (const part of value.split(',')) {
|
|
191
|
-
const piece = part.trim();
|
|
192
|
-
const pipe = piece.indexOf('|');
|
|
193
|
-
if (pipe <= 0) continue;
|
|
194
|
-
const name = piece.slice(0, pipe).trim();
|
|
195
|
-
const url = piece.slice(pipe + 1).trim();
|
|
196
|
-
if (name && url) rows.push({ name, url, initials: initialsFromName(name) });
|
|
197
|
-
}
|
|
198
|
-
return rows;
|
|
199
|
-
}
|
|
200
|
-
if (!Array.isArray(value)) return rows;
|
|
201
|
-
for (const item of value) {
|
|
202
|
-
if (!item || typeof item !== 'object') continue;
|
|
203
|
-
const rowIn = /** @type {Record<string, unknown>} */ (item);
|
|
204
|
-
const name = String(rowIn.name ?? '').trim();
|
|
205
|
-
const url = String(rowIn.url ?? '').trim();
|
|
206
|
-
if (!name || !url) continue;
|
|
207
|
-
/** @type {DirectoryRow} */
|
|
208
|
-
const row = { name, url };
|
|
209
|
-
const initials = rowIn.initials != null ? String(rowIn.initials).trim() : '';
|
|
210
|
-
row.initials = initials || initialsFromName(name);
|
|
211
|
-
if (rowIn.icon != null && String(rowIn.icon).trim()) row.icon = String(rowIn.icon).trim();
|
|
212
|
-
if (rowIn.badge_image != null && String(rowIn.badge_image).trim()) {
|
|
213
|
-
row.badge_image = String(rowIn.badge_image).trim();
|
|
214
|
-
}
|
|
215
|
-
rows.push(row);
|
|
216
|
-
}
|
|
217
|
-
return rows;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Normalize website type to a canonical `site.json.site_type` value.
|
|
222
|
-
* Accepts canonical values and common human-ish aliases; blank/invalid → multipage.
|
|
223
|
-
* Always returns only: one-page | multipage | seo (never weakens validate:data).
|
|
224
|
-
* @param {unknown} value
|
|
225
|
-
* @returns {SiteType}
|
|
226
|
-
*/
|
|
227
|
-
export function normalizeSiteType(value) {
|
|
228
|
-
if (value == null) return DEFAULT_SITE_TYPE;
|
|
229
|
-
const raw = String(value)
|
|
230
|
-
.trim()
|
|
231
|
-
.toLowerCase()
|
|
232
|
-
.replace(/[_]+/g, '-')
|
|
233
|
-
.replace(/\s+/g, '-');
|
|
234
|
-
if (!raw) return DEFAULT_SITE_TYPE;
|
|
235
|
-
|
|
236
|
-
/** @type {Record<string, SiteType>} */
|
|
237
|
-
const aliases = {
|
|
238
|
-
'one-page': 'one-page',
|
|
239
|
-
onepage: 'one-page',
|
|
240
|
-
one: 'one-page',
|
|
241
|
-
single: 'one-page',
|
|
242
|
-
'single-page': 'one-page',
|
|
243
|
-
singlepage: 'one-page',
|
|
244
|
-
multipage: 'multipage',
|
|
245
|
-
'multi-page': 'multipage',
|
|
246
|
-
multi: 'multipage',
|
|
247
|
-
seo: 'seo',
|
|
248
|
-
};
|
|
249
|
-
return aliases[raw] || DEFAULT_SITE_TYPE;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* @param {string} label
|
|
254
|
-
* @param {{ required?: boolean, defaultValue?: string }} [opts]
|
|
255
|
-
* @param {import('node:readline/promises').Interface} rl
|
|
256
|
-
* @returns {Promise<string>}
|
|
257
|
-
*/
|
|
258
|
-
async function ask(rl, label, opts = {}) {
|
|
259
|
-
const { required = true, defaultValue = '' } = opts;
|
|
260
|
-
while (true) {
|
|
261
|
-
const hint = defaultValue ? ` [${defaultValue}]` : '';
|
|
262
|
-
const raw = await rl.question(`${label}${hint}: `);
|
|
263
|
-
const answer = raw.trim();
|
|
264
|
-
if (answer) return answer;
|
|
265
|
-
if (defaultValue) return defaultValue;
|
|
266
|
-
if (!required) return '';
|
|
267
|
-
console.log(' This field is required. Please enter a value (or Ctrl+C to abort).');
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Collect required client fields via readline (no extra deps).
|
|
273
|
-
*
|
|
274
|
-
* @returns {Promise<ScaffoldAnswers>}
|
|
275
|
-
*/
|
|
276
|
-
export async function collectClientAnswers() {
|
|
277
|
-
const rl = readline.createInterface({ input, output });
|
|
278
|
-
|
|
279
|
-
try {
|
|
280
|
-
console.log('\nEnter client business details (required fields re-prompt if empty).\n');
|
|
281
|
-
|
|
282
|
-
const businessName = await ask(rl, 'Business name');
|
|
283
|
-
const legalName = await ask(rl, 'Legal business name', {
|
|
284
|
-
defaultValue: `${businessName} LLC`,
|
|
285
|
-
});
|
|
286
|
-
const tagline = await ask(rl, 'Tagline');
|
|
287
|
-
const phone = await ask(rl, 'Primary phone');
|
|
288
|
-
const email = await ask(rl, 'Primary email');
|
|
289
|
-
const street = await ask(rl, 'Street address');
|
|
290
|
-
const city = await ask(rl, 'City');
|
|
291
|
-
const state = await ask(rl, 'State');
|
|
292
|
-
const zip = await ask(rl, 'ZIP');
|
|
293
|
-
const serviceArea = await ask(rl, 'Service area description');
|
|
294
|
-
const freeEstimate = await ask(rl, 'Free-estimate wording', {
|
|
295
|
-
defaultValue: DEFAULT_FREE_ESTIMATE,
|
|
296
|
-
});
|
|
297
|
-
const yearsExperience = await ask(rl, 'Years of experience', {
|
|
298
|
-
defaultValue: DEFAULT_YEARS_EXPERIENCE,
|
|
299
|
-
});
|
|
300
|
-
const license = await ask(rl, 'License text', {
|
|
301
|
-
defaultValue: DEFAULT_LICENSE,
|
|
302
|
-
});
|
|
303
|
-
const insurance = await ask(rl, 'Insurance statement', {
|
|
304
|
-
defaultValue: DEFAULT_INSURANCE,
|
|
305
|
-
});
|
|
306
|
-
const foundedYear = await ask(rl, 'Founded year (optional)', {
|
|
307
|
-
required: false,
|
|
308
|
-
defaultValue: '',
|
|
309
|
-
});
|
|
310
|
-
const paymentMethodsRaw = await ask(rl, 'Payment methods (comma-separated)', {
|
|
311
|
-
defaultValue: DEFAULT_PAYMENT_METHODS.join(', '),
|
|
312
|
-
});
|
|
313
|
-
const hoursWeekday = await ask(rl, 'Weekday hours (Mon–Fri)', {
|
|
314
|
-
defaultValue: DEFAULT_HOURS[0].time,
|
|
315
|
-
});
|
|
316
|
-
const hoursSaturday = await ask(rl, 'Saturday hours', {
|
|
317
|
-
defaultValue: DEFAULT_HOURS[1].time,
|
|
318
|
-
});
|
|
319
|
-
const hoursSunday = await ask(rl, 'Sunday hours', {
|
|
320
|
-
defaultValue: DEFAULT_HOURS[2].time,
|
|
321
|
-
});
|
|
322
|
-
const socialRaw = await ask(rl, 'Social links (network=url,…; Enter to skip)', {
|
|
323
|
-
required: false,
|
|
324
|
-
defaultValue: '',
|
|
325
|
-
});
|
|
326
|
-
const directoriesRaw = await ask(rl, 'Directories (Name|url,…; Enter to skip)', {
|
|
327
|
-
required: false,
|
|
328
|
-
defaultValue: '',
|
|
329
|
-
});
|
|
330
|
-
const siteTypeRaw = await ask(rl, 'Website type (one-page | multipage | seo)', {
|
|
331
|
-
defaultValue: DEFAULT_SITE_TYPE,
|
|
332
|
-
});
|
|
333
|
-
const servicesRaw = await ask(rl, 'Primary services (comma-separated)');
|
|
334
|
-
const siteUrl = await ask(rl, 'Site URL (optional)', {
|
|
335
|
-
required: false,
|
|
336
|
-
defaultValue: '',
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
const primaryServices = servicesRaw
|
|
340
|
-
.split(',')
|
|
341
|
-
.map((s) => s.trim())
|
|
342
|
-
.filter(Boolean);
|
|
343
|
-
|
|
344
|
-
if (primaryServices.length === 0) {
|
|
345
|
-
throw new Error('At least one primary service is required.');
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return buildAnswers({
|
|
349
|
-
businessName,
|
|
350
|
-
legalName,
|
|
351
|
-
tagline,
|
|
352
|
-
phone,
|
|
353
|
-
email,
|
|
354
|
-
street,
|
|
355
|
-
city,
|
|
356
|
-
state,
|
|
357
|
-
zip,
|
|
358
|
-
serviceArea,
|
|
359
|
-
freeEstimate,
|
|
360
|
-
yearsExperience,
|
|
361
|
-
license,
|
|
362
|
-
insurance,
|
|
363
|
-
foundedYear,
|
|
364
|
-
paymentMethods: paymentMethodsRaw,
|
|
365
|
-
hoursWeekday,
|
|
366
|
-
hoursSaturday,
|
|
367
|
-
hoursSunday,
|
|
368
|
-
social: socialRaw,
|
|
369
|
-
directories: directoriesRaw,
|
|
370
|
-
siteType: siteTypeRaw,
|
|
371
|
-
primaryServices,
|
|
372
|
-
siteUrl: siteUrl || undefined,
|
|
373
|
-
});
|
|
374
|
-
} finally {
|
|
375
|
-
rl.close();
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* All answer paths funnel here. Hours: full rows or compact weekday/sat/sun.
|
|
381
|
-
* Social: object or `network=url` CSV. Directories: rows or `Name|url` CSV.
|
|
382
|
-
* siteType: canonical or human-ish → one-page | multipage | seo (default multipage).
|
|
383
|
-
*
|
|
384
|
-
* @param {Partial<ScaffoldAnswers> & {
|
|
385
|
-
* businessName: string,
|
|
386
|
-
* primaryServices: string[],
|
|
387
|
-
* paymentMethods?: string | string[],
|
|
388
|
-
* hoursWeekday?: string,
|
|
389
|
-
* hoursSaturday?: string,
|
|
390
|
-
* hoursSunday?: string,
|
|
391
|
-
* social?: string | Record<string, string>,
|
|
392
|
-
* directories?: string | DirectoryRow[],
|
|
393
|
-
* siteType?: string,
|
|
394
|
-
* }} overrides
|
|
395
|
-
* @returns {ScaffoldAnswers}
|
|
396
|
-
*/
|
|
397
|
-
export function buildAnswers(overrides) {
|
|
398
|
-
const businessName = requiredText(overrides.businessName, '');
|
|
399
|
-
if (!businessName) throw new Error('businessName is required');
|
|
400
|
-
|
|
401
|
-
const primaryServices = (overrides.primaryServices || [])
|
|
402
|
-
.map((s) => String(s || '').trim())
|
|
403
|
-
.filter(Boolean);
|
|
404
|
-
if (primaryServices.length === 0) {
|
|
405
|
-
throw new Error('primaryServices must include at least one service');
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const hasCompactHours =
|
|
409
|
-
overrides.hoursWeekday != null ||
|
|
410
|
-
overrides.hoursSaturday != null ||
|
|
411
|
-
overrides.hoursSunday != null;
|
|
412
|
-
/** @type {unknown} */
|
|
413
|
-
let hoursInput = overrides.hours;
|
|
414
|
-
if (
|
|
415
|
-
hasCompactHours &&
|
|
416
|
-
(hoursInput == null || (Array.isArray(hoursInput) && hoursInput.length === 0))
|
|
417
|
-
) {
|
|
418
|
-
hoursInput = [overrides.hoursWeekday, overrides.hoursSaturday, overrides.hoursSunday];
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const social = normalizeSocial(/** @type {{ social?: unknown }} */ (overrides).social);
|
|
422
|
-
const directories = normalizeDirectories(
|
|
423
|
-
/** @type {{ directories?: unknown }} */ (overrides).directories,
|
|
424
|
-
);
|
|
425
|
-
// Derived from rows only: directories:[] + enableDirectories:true → false.
|
|
426
|
-
const enableDirectories = directories.length > 0;
|
|
427
|
-
const siteType = normalizeSiteType(
|
|
428
|
-
/** @type {{ siteType?: unknown }} */ (overrides).siteType,
|
|
429
|
-
);
|
|
430
|
-
|
|
431
|
-
return {
|
|
432
|
-
businessName,
|
|
433
|
-
legalName: requiredText(overrides.legalName, `${businessName} LLC`),
|
|
434
|
-
tagline: requiredText(overrides.tagline, `Quality work from ${businessName}.`),
|
|
435
|
-
phone: requiredText(overrides.phone, '(555) 555-0100'),
|
|
436
|
-
email: requiredText(overrides.email, 'hello@example.com'),
|
|
437
|
-
street: requiredText(overrides.street, '123 Main St'),
|
|
438
|
-
city: requiredText(overrides.city, 'Example City'),
|
|
439
|
-
state: requiredText(overrides.state, 'VA'),
|
|
440
|
-
zip: requiredText(overrides.zip, '00000'),
|
|
441
|
-
serviceArea: requiredText(
|
|
442
|
-
overrides.serviceArea,
|
|
443
|
-
`${requiredText(overrides.city, 'Example City')} and surrounding areas`,
|
|
444
|
-
),
|
|
445
|
-
freeEstimate: requiredText(overrides.freeEstimate, DEFAULT_FREE_ESTIMATE),
|
|
446
|
-
yearsExperience: requiredText(overrides.yearsExperience, DEFAULT_YEARS_EXPERIENCE),
|
|
447
|
-
license: requiredText(overrides.license, DEFAULT_LICENSE),
|
|
448
|
-
insurance: requiredText(overrides.insurance, DEFAULT_INSURANCE),
|
|
449
|
-
foundedYear: optionalText(overrides.foundedYear),
|
|
450
|
-
paymentMethods: normalizePaymentMethods(
|
|
451
|
-
/** @type {{ paymentMethods?: unknown }} */ (overrides).paymentMethods,
|
|
452
|
-
),
|
|
453
|
-
hours: normalizeHours(hoursInput),
|
|
454
|
-
social,
|
|
455
|
-
directories,
|
|
456
|
-
enableDirectories,
|
|
457
|
-
siteType,
|
|
458
|
-
primaryServices,
|
|
459
|
-
siteUrl: overrides.siteUrl ? String(overrides.siteUrl).trim() || undefined : undefined,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
1
|
+
import * as readline from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
|
|
4
|
+
/** Default free-estimate CTA when blank/omitted. */
|
|
5
|
+
export const DEFAULT_FREE_ESTIMATE = 'Free On-Site Estimate';
|
|
6
|
+
/** Default years-of-experience text when blank/omitted. */
|
|
7
|
+
export const DEFAULT_YEARS_EXPERIENCE = '10+';
|
|
8
|
+
/** Default license statement when blank/omitted. */
|
|
9
|
+
export const DEFAULT_LICENSE = 'Licensed & Insured';
|
|
10
|
+
/** Default insurance statement when blank/omitted. */
|
|
11
|
+
export const DEFAULT_INSURANCE =
|
|
12
|
+
"Fully insured with general liability and workers' compensation.";
|
|
13
|
+
/** Default payment methods when blank/omitted/empty. */
|
|
14
|
+
export const DEFAULT_PAYMENT_METHODS = [
|
|
15
|
+
'Cash',
|
|
16
|
+
'Check',
|
|
17
|
+
'Credit Card',
|
|
18
|
+
'Financing Available',
|
|
19
|
+
];
|
|
20
|
+
/** Default 3-row business hours (Mon–Fri / Sat / Sun). */
|
|
21
|
+
export const DEFAULT_HOURS = [
|
|
22
|
+
{ days: 'Monday - Friday', time: '7:00 AM - 6:00 PM' },
|
|
23
|
+
{ days: 'Saturday', time: '8:00 AM - 2:00 PM' },
|
|
24
|
+
{ days: 'Sunday', time: 'Closed' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Canonical `site.json.site_type` values written by new scaffolds. */
|
|
28
|
+
export const SITE_TYPES = /** @type {const} */ (['one-page', 'multipage', 'seo']);
|
|
29
|
+
/** Default website type for NEW CLI scaffolds (all answer paths). */
|
|
30
|
+
export const DEFAULT_SITE_TYPE = /** @type {const} */ ('multipage');
|
|
31
|
+
|
|
32
|
+
/** Allowed `business.social` keys (blank values omitted, never empty strings). */
|
|
33
|
+
export const SOCIAL_NETWORK_KEYS = [
|
|
34
|
+
'facebook', 'instagram', 'youtube', 'tiktok', 'google_business', 'linkedin', 'x',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {'one-page' | 'multipage' | 'seo'} SiteType
|
|
39
|
+
* @typedef {{ days: string, time: string }} BusinessHourRow
|
|
40
|
+
* @typedef {{ name: string, url: string, initials?: string, icon?: string, badge_image?: string }} DirectoryRow
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} ScaffoldAnswers
|
|
43
|
+
* @property {string} businessName
|
|
44
|
+
* @property {string} legalName
|
|
45
|
+
* @property {string} tagline
|
|
46
|
+
* @property {string} phone
|
|
47
|
+
* @property {string} email
|
|
48
|
+
* @property {string} street
|
|
49
|
+
* @property {string} city
|
|
50
|
+
* @property {string} state
|
|
51
|
+
* @property {string} zip
|
|
52
|
+
* @property {string} serviceArea
|
|
53
|
+
* @property {string} foundedYear empty string when skipped/blank
|
|
54
|
+
* @property {string} freeEstimate
|
|
55
|
+
* @property {string} yearsExperience
|
|
56
|
+
* @property {string} license
|
|
57
|
+
* @property {string} insurance
|
|
58
|
+
* @property {string[]} paymentMethods
|
|
59
|
+
* @property {BusinessHourRow[]} hours always length 3
|
|
60
|
+
* @property {Record<string, string>} social non-blank network keys only
|
|
61
|
+
* @property {DirectoryRow[]} directories empty = none (keep template row + disable)
|
|
62
|
+
* @property {boolean} enableDirectories true iff directories.length > 0
|
|
63
|
+
* @property {SiteType} siteType written to site.json.site_type
|
|
64
|
+
* @property {string[]} primaryServices
|
|
65
|
+
* @property {string} [siteUrl]
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Trim and fall back when the value is blank-looking.
|
|
70
|
+
* @param {unknown} value
|
|
71
|
+
* @param {string} fallback
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function requiredText(value, fallback) {
|
|
75
|
+
const text = value == null ? '' : String(value).trim();
|
|
76
|
+
return text || fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Optional text field: trim; blank/whitespace → "".
|
|
81
|
+
* @param {unknown} value
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function optionalText(value) {
|
|
85
|
+
if (value == null) return '';
|
|
86
|
+
return String(value).trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* CSV string or string[] → trimmed non-empty list; blank/empty → defaults (never []).
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function normalizePaymentMethods(value) {
|
|
95
|
+
/** @type {string[]} */
|
|
96
|
+
let items = [];
|
|
97
|
+
if (typeof value === 'string') {
|
|
98
|
+
items = value
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((s) => s.trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
} else if (Array.isArray(value)) {
|
|
103
|
+
items = value.map((s) => String(s ?? '').trim()).filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
return items.length > 0 ? items : [...DEFAULT_PAYMENT_METHODS];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Normalize hours to the fixed 3-row `{ days, time }` shape.
|
|
110
|
+
* Accepts full rows, time-only strings, or compact weekday/sat/sun fields via buildAnswers.
|
|
111
|
+
* Blank/missing rows fall back to defaults — never empty array or partial shape.
|
|
112
|
+
* @param {unknown} value
|
|
113
|
+
* @returns {BusinessHourRow[]}
|
|
114
|
+
*/
|
|
115
|
+
export function normalizeHours(value) {
|
|
116
|
+
const defaults = DEFAULT_HOURS.map((row) => ({ days: row.days, time: row.time }));
|
|
117
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
118
|
+
return defaults;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return defaults.map((def, index) => {
|
|
122
|
+
const raw = value[index];
|
|
123
|
+
if (raw == null || raw === '') {
|
|
124
|
+
return { ...def };
|
|
125
|
+
}
|
|
126
|
+
if (typeof raw === 'string') {
|
|
127
|
+
const time = raw.trim();
|
|
128
|
+
return { days: def.days, time: time || def.time };
|
|
129
|
+
}
|
|
130
|
+
if (typeof raw === 'object') {
|
|
131
|
+
const days =
|
|
132
|
+
/** @type {{ days?: unknown, time?: unknown }} */ (raw).days != null
|
|
133
|
+
? String(/** @type {{ days?: unknown }} */ (raw).days).trim()
|
|
134
|
+
: '';
|
|
135
|
+
const time =
|
|
136
|
+
/** @type {{ time?: unknown }} */ (raw).time != null
|
|
137
|
+
? String(/** @type {{ time?: unknown }} */ (raw).time).trim()
|
|
138
|
+
: '';
|
|
139
|
+
return {
|
|
140
|
+
days: days || def.days,
|
|
141
|
+
time: time || def.time,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { ...def };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {string} name @returns {string} */
|
|
149
|
+
export function initialsFromName(name) {
|
|
150
|
+
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
|
151
|
+
if (parts.length === 0) return '?';
|
|
152
|
+
if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase();
|
|
153
|
+
return parts.map((p) => p[0]).join('').slice(0, 3).toUpperCase();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Object map or `network=url` CSV → non-blank keys only. @param {unknown} value */
|
|
157
|
+
export function normalizeSocial(value) {
|
|
158
|
+
/** @type {Record<string, string>} */
|
|
159
|
+
const out = {};
|
|
160
|
+
const allowed = new Set(SOCIAL_NETWORK_KEYS);
|
|
161
|
+
if (value == null || value === '') return out;
|
|
162
|
+
if (typeof value === 'string') {
|
|
163
|
+
for (const part of value.split(',')) {
|
|
164
|
+
const piece = part.trim();
|
|
165
|
+
const eq = piece.indexOf('=');
|
|
166
|
+
if (eq <= 0) continue;
|
|
167
|
+
const key = piece.slice(0, eq).trim().toLowerCase().replace(/-/g, '_');
|
|
168
|
+
const url = piece.slice(eq + 1).trim();
|
|
169
|
+
if (allowed.has(key) && url) out[key] = url;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
174
|
+
for (const key of SOCIAL_NETWORK_KEYS) {
|
|
175
|
+
const raw = /** @type {Record<string, unknown>} */ (value)[key];
|
|
176
|
+
if (raw == null) continue;
|
|
177
|
+
const url = String(raw).trim();
|
|
178
|
+
if (url) out[key] = url;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Rows or `Name|url` CSV. Empty → [] (replace keeps ≥1 template row). @param {unknown} value */
|
|
185
|
+
export function normalizeDirectories(value) {
|
|
186
|
+
/** @type {DirectoryRow[]} */
|
|
187
|
+
const rows = [];
|
|
188
|
+
if (value == null || value === '') return rows;
|
|
189
|
+
if (typeof value === 'string') {
|
|
190
|
+
for (const part of value.split(',')) {
|
|
191
|
+
const piece = part.trim();
|
|
192
|
+
const pipe = piece.indexOf('|');
|
|
193
|
+
if (pipe <= 0) continue;
|
|
194
|
+
const name = piece.slice(0, pipe).trim();
|
|
195
|
+
const url = piece.slice(pipe + 1).trim();
|
|
196
|
+
if (name && url) rows.push({ name, url, initials: initialsFromName(name) });
|
|
197
|
+
}
|
|
198
|
+
return rows;
|
|
199
|
+
}
|
|
200
|
+
if (!Array.isArray(value)) return rows;
|
|
201
|
+
for (const item of value) {
|
|
202
|
+
if (!item || typeof item !== 'object') continue;
|
|
203
|
+
const rowIn = /** @type {Record<string, unknown>} */ (item);
|
|
204
|
+
const name = String(rowIn.name ?? '').trim();
|
|
205
|
+
const url = String(rowIn.url ?? '').trim();
|
|
206
|
+
if (!name || !url) continue;
|
|
207
|
+
/** @type {DirectoryRow} */
|
|
208
|
+
const row = { name, url };
|
|
209
|
+
const initials = rowIn.initials != null ? String(rowIn.initials).trim() : '';
|
|
210
|
+
row.initials = initials || initialsFromName(name);
|
|
211
|
+
if (rowIn.icon != null && String(rowIn.icon).trim()) row.icon = String(rowIn.icon).trim();
|
|
212
|
+
if (rowIn.badge_image != null && String(rowIn.badge_image).trim()) {
|
|
213
|
+
row.badge_image = String(rowIn.badge_image).trim();
|
|
214
|
+
}
|
|
215
|
+
rows.push(row);
|
|
216
|
+
}
|
|
217
|
+
return rows;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Normalize website type to a canonical `site.json.site_type` value.
|
|
222
|
+
* Accepts canonical values and common human-ish aliases; blank/invalid → multipage.
|
|
223
|
+
* Always returns only: one-page | multipage | seo (never weakens validate:data).
|
|
224
|
+
* @param {unknown} value
|
|
225
|
+
* @returns {SiteType}
|
|
226
|
+
*/
|
|
227
|
+
export function normalizeSiteType(value) {
|
|
228
|
+
if (value == null) return DEFAULT_SITE_TYPE;
|
|
229
|
+
const raw = String(value)
|
|
230
|
+
.trim()
|
|
231
|
+
.toLowerCase()
|
|
232
|
+
.replace(/[_]+/g, '-')
|
|
233
|
+
.replace(/\s+/g, '-');
|
|
234
|
+
if (!raw) return DEFAULT_SITE_TYPE;
|
|
235
|
+
|
|
236
|
+
/** @type {Record<string, SiteType>} */
|
|
237
|
+
const aliases = {
|
|
238
|
+
'one-page': 'one-page',
|
|
239
|
+
onepage: 'one-page',
|
|
240
|
+
one: 'one-page',
|
|
241
|
+
single: 'one-page',
|
|
242
|
+
'single-page': 'one-page',
|
|
243
|
+
singlepage: 'one-page',
|
|
244
|
+
multipage: 'multipage',
|
|
245
|
+
'multi-page': 'multipage',
|
|
246
|
+
multi: 'multipage',
|
|
247
|
+
seo: 'seo',
|
|
248
|
+
};
|
|
249
|
+
return aliases[raw] || DEFAULT_SITE_TYPE;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {string} label
|
|
254
|
+
* @param {{ required?: boolean, defaultValue?: string }} [opts]
|
|
255
|
+
* @param {import('node:readline/promises').Interface} rl
|
|
256
|
+
* @returns {Promise<string>}
|
|
257
|
+
*/
|
|
258
|
+
async function ask(rl, label, opts = {}) {
|
|
259
|
+
const { required = true, defaultValue = '' } = opts;
|
|
260
|
+
while (true) {
|
|
261
|
+
const hint = defaultValue ? ` [${defaultValue}]` : '';
|
|
262
|
+
const raw = await rl.question(`${label}${hint}: `);
|
|
263
|
+
const answer = raw.trim();
|
|
264
|
+
if (answer) return answer;
|
|
265
|
+
if (defaultValue) return defaultValue;
|
|
266
|
+
if (!required) return '';
|
|
267
|
+
console.log(' This field is required. Please enter a value (or Ctrl+C to abort).');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Collect required client fields via readline (no extra deps).
|
|
273
|
+
*
|
|
274
|
+
* @returns {Promise<ScaffoldAnswers>}
|
|
275
|
+
*/
|
|
276
|
+
export async function collectClientAnswers() {
|
|
277
|
+
const rl = readline.createInterface({ input, output });
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
console.log('\nEnter client business details (required fields re-prompt if empty).\n');
|
|
281
|
+
|
|
282
|
+
const businessName = await ask(rl, 'Business name');
|
|
283
|
+
const legalName = await ask(rl, 'Legal business name', {
|
|
284
|
+
defaultValue: `${businessName} LLC`,
|
|
285
|
+
});
|
|
286
|
+
const tagline = await ask(rl, 'Tagline');
|
|
287
|
+
const phone = await ask(rl, 'Primary phone');
|
|
288
|
+
const email = await ask(rl, 'Primary email');
|
|
289
|
+
const street = await ask(rl, 'Street address');
|
|
290
|
+
const city = await ask(rl, 'City');
|
|
291
|
+
const state = await ask(rl, 'State');
|
|
292
|
+
const zip = await ask(rl, 'ZIP');
|
|
293
|
+
const serviceArea = await ask(rl, 'Service area description');
|
|
294
|
+
const freeEstimate = await ask(rl, 'Free-estimate wording', {
|
|
295
|
+
defaultValue: DEFAULT_FREE_ESTIMATE,
|
|
296
|
+
});
|
|
297
|
+
const yearsExperience = await ask(rl, 'Years of experience', {
|
|
298
|
+
defaultValue: DEFAULT_YEARS_EXPERIENCE,
|
|
299
|
+
});
|
|
300
|
+
const license = await ask(rl, 'License text', {
|
|
301
|
+
defaultValue: DEFAULT_LICENSE,
|
|
302
|
+
});
|
|
303
|
+
const insurance = await ask(rl, 'Insurance statement', {
|
|
304
|
+
defaultValue: DEFAULT_INSURANCE,
|
|
305
|
+
});
|
|
306
|
+
const foundedYear = await ask(rl, 'Founded year (optional)', {
|
|
307
|
+
required: false,
|
|
308
|
+
defaultValue: '',
|
|
309
|
+
});
|
|
310
|
+
const paymentMethodsRaw = await ask(rl, 'Payment methods (comma-separated)', {
|
|
311
|
+
defaultValue: DEFAULT_PAYMENT_METHODS.join(', '),
|
|
312
|
+
});
|
|
313
|
+
const hoursWeekday = await ask(rl, 'Weekday hours (Mon–Fri)', {
|
|
314
|
+
defaultValue: DEFAULT_HOURS[0].time,
|
|
315
|
+
});
|
|
316
|
+
const hoursSaturday = await ask(rl, 'Saturday hours', {
|
|
317
|
+
defaultValue: DEFAULT_HOURS[1].time,
|
|
318
|
+
});
|
|
319
|
+
const hoursSunday = await ask(rl, 'Sunday hours', {
|
|
320
|
+
defaultValue: DEFAULT_HOURS[2].time,
|
|
321
|
+
});
|
|
322
|
+
const socialRaw = await ask(rl, 'Social links (network=url,…; Enter to skip)', {
|
|
323
|
+
required: false,
|
|
324
|
+
defaultValue: '',
|
|
325
|
+
});
|
|
326
|
+
const directoriesRaw = await ask(rl, 'Directories (Name|url,…; Enter to skip)', {
|
|
327
|
+
required: false,
|
|
328
|
+
defaultValue: '',
|
|
329
|
+
});
|
|
330
|
+
const siteTypeRaw = await ask(rl, 'Website type (one-page | multipage | seo)', {
|
|
331
|
+
defaultValue: DEFAULT_SITE_TYPE,
|
|
332
|
+
});
|
|
333
|
+
const servicesRaw = await ask(rl, 'Primary services (comma-separated)');
|
|
334
|
+
const siteUrl = await ask(rl, 'Site URL (optional)', {
|
|
335
|
+
required: false,
|
|
336
|
+
defaultValue: '',
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const primaryServices = servicesRaw
|
|
340
|
+
.split(',')
|
|
341
|
+
.map((s) => s.trim())
|
|
342
|
+
.filter(Boolean);
|
|
343
|
+
|
|
344
|
+
if (primaryServices.length === 0) {
|
|
345
|
+
throw new Error('At least one primary service is required.');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return buildAnswers({
|
|
349
|
+
businessName,
|
|
350
|
+
legalName,
|
|
351
|
+
tagline,
|
|
352
|
+
phone,
|
|
353
|
+
email,
|
|
354
|
+
street,
|
|
355
|
+
city,
|
|
356
|
+
state,
|
|
357
|
+
zip,
|
|
358
|
+
serviceArea,
|
|
359
|
+
freeEstimate,
|
|
360
|
+
yearsExperience,
|
|
361
|
+
license,
|
|
362
|
+
insurance,
|
|
363
|
+
foundedYear,
|
|
364
|
+
paymentMethods: paymentMethodsRaw,
|
|
365
|
+
hoursWeekday,
|
|
366
|
+
hoursSaturday,
|
|
367
|
+
hoursSunday,
|
|
368
|
+
social: socialRaw,
|
|
369
|
+
directories: directoriesRaw,
|
|
370
|
+
siteType: siteTypeRaw,
|
|
371
|
+
primaryServices,
|
|
372
|
+
siteUrl: siteUrl || undefined,
|
|
373
|
+
});
|
|
374
|
+
} finally {
|
|
375
|
+
rl.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* All answer paths funnel here. Hours: full rows or compact weekday/sat/sun.
|
|
381
|
+
* Social: object or `network=url` CSV. Directories: rows or `Name|url` CSV.
|
|
382
|
+
* siteType: canonical or human-ish → one-page | multipage | seo (default multipage).
|
|
383
|
+
*
|
|
384
|
+
* @param {Partial<ScaffoldAnswers> & {
|
|
385
|
+
* businessName: string,
|
|
386
|
+
* primaryServices: string[],
|
|
387
|
+
* paymentMethods?: string | string[],
|
|
388
|
+
* hoursWeekday?: string,
|
|
389
|
+
* hoursSaturday?: string,
|
|
390
|
+
* hoursSunday?: string,
|
|
391
|
+
* social?: string | Record<string, string>,
|
|
392
|
+
* directories?: string | DirectoryRow[],
|
|
393
|
+
* siteType?: string,
|
|
394
|
+
* }} overrides
|
|
395
|
+
* @returns {ScaffoldAnswers}
|
|
396
|
+
*/
|
|
397
|
+
export function buildAnswers(overrides) {
|
|
398
|
+
const businessName = requiredText(overrides.businessName, '');
|
|
399
|
+
if (!businessName) throw new Error('businessName is required');
|
|
400
|
+
|
|
401
|
+
const primaryServices = (overrides.primaryServices || [])
|
|
402
|
+
.map((s) => String(s || '').trim())
|
|
403
|
+
.filter(Boolean);
|
|
404
|
+
if (primaryServices.length === 0) {
|
|
405
|
+
throw new Error('primaryServices must include at least one service');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const hasCompactHours =
|
|
409
|
+
overrides.hoursWeekday != null ||
|
|
410
|
+
overrides.hoursSaturday != null ||
|
|
411
|
+
overrides.hoursSunday != null;
|
|
412
|
+
/** @type {unknown} */
|
|
413
|
+
let hoursInput = overrides.hours;
|
|
414
|
+
if (
|
|
415
|
+
hasCompactHours &&
|
|
416
|
+
(hoursInput == null || (Array.isArray(hoursInput) && hoursInput.length === 0))
|
|
417
|
+
) {
|
|
418
|
+
hoursInput = [overrides.hoursWeekday, overrides.hoursSaturday, overrides.hoursSunday];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const social = normalizeSocial(/** @type {{ social?: unknown }} */ (overrides).social);
|
|
422
|
+
const directories = normalizeDirectories(
|
|
423
|
+
/** @type {{ directories?: unknown }} */ (overrides).directories,
|
|
424
|
+
);
|
|
425
|
+
// Derived from rows only: directories:[] + enableDirectories:true → false.
|
|
426
|
+
const enableDirectories = directories.length > 0;
|
|
427
|
+
const siteType = normalizeSiteType(
|
|
428
|
+
/** @type {{ siteType?: unknown }} */ (overrides).siteType,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
businessName,
|
|
433
|
+
legalName: requiredText(overrides.legalName, `${businessName} LLC`),
|
|
434
|
+
tagline: requiredText(overrides.tagline, `Quality work from ${businessName}.`),
|
|
435
|
+
phone: requiredText(overrides.phone, '(555) 555-0100'),
|
|
436
|
+
email: requiredText(overrides.email, 'hello@example.com'),
|
|
437
|
+
street: requiredText(overrides.street, '123 Main St'),
|
|
438
|
+
city: requiredText(overrides.city, 'Example City'),
|
|
439
|
+
state: requiredText(overrides.state, 'VA'),
|
|
440
|
+
zip: requiredText(overrides.zip, '00000'),
|
|
441
|
+
serviceArea: requiredText(
|
|
442
|
+
overrides.serviceArea,
|
|
443
|
+
`${requiredText(overrides.city, 'Example City')} and surrounding areas`,
|
|
444
|
+
),
|
|
445
|
+
freeEstimate: requiredText(overrides.freeEstimate, DEFAULT_FREE_ESTIMATE),
|
|
446
|
+
yearsExperience: requiredText(overrides.yearsExperience, DEFAULT_YEARS_EXPERIENCE),
|
|
447
|
+
license: requiredText(overrides.license, DEFAULT_LICENSE),
|
|
448
|
+
insurance: requiredText(overrides.insurance, DEFAULT_INSURANCE),
|
|
449
|
+
foundedYear: optionalText(overrides.foundedYear),
|
|
450
|
+
paymentMethods: normalizePaymentMethods(
|
|
451
|
+
/** @type {{ paymentMethods?: unknown }} */ (overrides).paymentMethods,
|
|
452
|
+
),
|
|
453
|
+
hours: normalizeHours(hoursInput),
|
|
454
|
+
social,
|
|
455
|
+
directories,
|
|
456
|
+
enableDirectories,
|
|
457
|
+
siteType,
|
|
458
|
+
primaryServices,
|
|
459
|
+
siteUrl: overrides.siteUrl ? String(overrides.siteUrl).trim() || undefined : undefined,
|
|
460
|
+
};
|
|
461
|
+
}
|