create-contractor-site 2.1.1 → 2.2.0

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.
@@ -27,7 +27,7 @@ export const DEFAULT_TEMPLATE_REPO =
27
27
  'https://github.com/glacayo/website-multipages.git';
28
28
 
29
29
  export const DEFAULT_TEMPLATE_REF =
30
- process.env.CREATE_CONTRACTOR_TEMPLATE_REF || 'v2.1.1';
30
+ process.env.CREATE_CONTRACTOR_TEMPLATE_REF || 'v2.2.0';
31
31
 
32
32
  /**
33
33
  * @param {string} name basename
package/src/prompts.mjs CHANGED
@@ -1,7 +1,44 @@
1
1
  import * as readline from 'node:readline/promises';
2
2
  import { stdin as input, stdout as output } from 'node:process';
3
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
+
4
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
+ *
5
42
  * @typedef {object} ScaffoldAnswers
6
43
  * @property {string} businessName
7
44
  * @property {string} legalName
@@ -13,11 +50,205 @@ import { stdin as input, stdout as output } from 'node:process';
13
50
  * @property {string} state
14
51
  * @property {string} zip
15
52
  * @property {string} serviceArea
16
- * @property {string} foundedYear
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
17
64
  * @property {string[]} primaryServices
18
65
  * @property {string} [siteUrl]
19
66
  */
20
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
+
21
252
  /**
22
253
  * @param {string} label
23
254
  * @param {{ required?: boolean, defaultValue?: string }} [opts]
@@ -31,7 +262,8 @@ async function ask(rl, label, opts = {}) {
31
262
  const raw = await rl.question(`${label}${hint}: `);
32
263
  const answer = raw.trim();
33
264
  if (answer) return answer;
34
- if (!required) return defaultValue;
265
+ if (defaultValue) return defaultValue;
266
+ if (!required) return '';
35
267
  console.log(' This field is required. Please enter a value (or Ctrl+C to abort).');
36
268
  }
37
269
  }
@@ -59,7 +291,45 @@ export async function collectClientAnswers() {
59
291
  const state = await ask(rl, 'State');
60
292
  const zip = await ask(rl, 'ZIP');
61
293
  const serviceArea = await ask(rl, 'Service area description');
62
- const foundedYear = await ask(rl, 'Founded year');
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
+ });
63
333
  const servicesRaw = await ask(rl, 'Primary services (comma-separated)');
64
334
  const siteUrl = await ask(rl, 'Site URL (optional)', {
65
335
  required: false,
@@ -75,7 +345,7 @@ export async function collectClientAnswers() {
75
345
  throw new Error('At least one primary service is required.');
76
346
  }
77
347
 
78
- return {
348
+ return buildAnswers({
79
349
  businessName,
80
350
  legalName,
81
351
  tagline,
@@ -86,36 +356,106 @@ export async function collectClientAnswers() {
86
356
  state,
87
357
  zip,
88
358
  serviceArea,
359
+ freeEstimate,
360
+ yearsExperience,
361
+ license,
362
+ insurance,
89
363
  foundedYear,
364
+ paymentMethods: paymentMethodsRaw,
365
+ hoursWeekday,
366
+ hoursSaturday,
367
+ hoursSunday,
368
+ social: socialRaw,
369
+ directories: directoriesRaw,
370
+ siteType: siteTypeRaw,
90
371
  primaryServices,
91
372
  siteUrl: siteUrl || undefined,
92
- };
373
+ });
93
374
  } finally {
94
375
  rl.close();
95
376
  }
96
377
  }
97
378
 
98
379
  /**
99
- * Non-interactive answers helper for scripted verification.
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).
100
383
  *
101
- * @param {Partial<ScaffoldAnswers> & { businessName: string, primaryServices: string[] }} overrides
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
102
395
  * @returns {ScaffoldAnswers}
103
396
  */
104
397
  export function buildAnswers(overrides) {
105
- const businessName = overrides.businessName;
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
+
106
431
  return {
107
432
  businessName,
108
- legalName: overrides.legalName ?? `${businessName} LLC`,
109
- tagline: overrides.tagline ?? `Quality work from ${businessName}.`,
110
- phone: overrides.phone ?? '(555) 555-0100',
111
- email: overrides.email ?? 'hello@example.com',
112
- street: overrides.street ?? '123 Main St',
113
- city: overrides.city ?? 'Example City',
114
- state: overrides.state ?? 'VA',
115
- zip: overrides.zip ?? '00000',
116
- serviceArea: overrides.serviceArea ?? `${overrides.city ?? 'Example City'} and surrounding areas`,
117
- foundedYear: overrides.foundedYear ?? '2020',
118
- primaryServices: overrides.primaryServices,
119
- siteUrl: overrides.siteUrl,
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,
120
460
  };
121
461
  }
@@ -373,7 +373,19 @@ export function replaceTargetData(targetDir, answers) {
373
373
  full: fullAddress,
374
374
  };
375
375
  business.service_area = answers.serviceArea;
376
+ business.free_estimate = answers.freeEstimate;
377
+ business.years_experience = answers.yearsExperience;
378
+ business.license = answers.license;
379
+ business.insurance = answers.insurance;
380
+ // Optional: always preserve key; skipped/blank → ""
376
381
  business.founded_year = answers.foundedYear;
382
+ // Always write non-empty arrays from buildAnswers (never [] / never skip).
383
+ business.payment_methods = answers.paymentMethods.map((m) => String(m));
384
+ business.hours = answers.hours.map((row) => ({
385
+ days: String(row.days),
386
+ time: String(row.time),
387
+ }));
388
+ business.social = { ...(answers.social || {}) }; // non-blank keys only
377
389
  business.type_of_services = typeOfServices;
378
390
 
379
391
  if (aligned) {
@@ -385,15 +397,42 @@ export function replaceTargetData(targetDir, answers) {
385
397
  if (answers.siteUrl) {
386
398
  site.url = answers.siteUrl.replace(/\/$/, '');
387
399
  }
388
- site.logo = {
389
- ...site.logo,
390
- alt: answers.businessName,
391
- };
400
+ // New scaffolds always write canonical site_type (default multipage via buildAnswers).
401
+ site.site_type = answers.siteType;
402
+ site.logo = { ...site.logo, alt: answers.businessName };
392
403
  site.seo = {
393
404
  ...site.seo,
394
405
  default_title: `${answers.businessName} | ${answers.city}, ${answers.state}`,
395
406
  default_description: `${answers.tagline} Serving ${answers.serviceArea}.`,
396
407
  };
408
+ site.features = {
409
+ ...site.features,
410
+ enable_directories: Boolean(answers.enableDirectories),
411
+ };
412
+ });
413
+
414
+ updateJsonFile(path.join(dataDir, 'directories.json'), (directoriesData) => {
415
+ const provided = Array.isArray(answers.directories) ? answers.directories : [];
416
+ if (provided.length > 0) {
417
+ directoriesData.directories = provided.map((row) => {
418
+ /** @type {Record<string, unknown>} */
419
+ const out = { name: String(row.name), url: String(row.url) };
420
+ if (row.initials) out.initials = String(row.initials);
421
+ if (row.icon) out.icon = String(row.icon);
422
+ if (row.badge_image) out.badge_image = String(row.badge_image);
423
+ return out;
424
+ });
425
+ return;
426
+ }
427
+ // none: keep existing rows; never write directories: []
428
+ const existing = Array.isArray(directoriesData.directories)
429
+ ? directoriesData.directories
430
+ : [];
431
+ if (existing.length === 0) {
432
+ directoriesData.directories = [
433
+ { name: 'Google Business', url: 'https://example.com/business', initials: 'G' },
434
+ ];
435
+ }
397
436
  });
398
437
 
399
438
  updateJsonFile(path.join(dataDir, 'areas.json'), (areas) => {