Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions src/lib/template-page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,42 @@ test('rejects the old layout, too few cards, and too many', () => {
assert.equal(parseBenefits(undefined), null);
});

test('the definition names the template; the FAQ heading uses the keyword', () => {
test('the definition and the FAQ heading both use the keyword', () => {
const copy = templateCopy({
name: 'Client project tracker',
seoKeyword: 'project tracker template',
seoTitle: 'Free Project Tracker Template for Clients',
definitionBody: 'A shared view.',
});
assert.equal(copy.definition?.heading, 'What is a client project tracker?');
assert.equal(copy.definition?.heading, 'What is a project tracker template?');
assert.equal(
templateCopy({ name: 'Booking app', seoKeyword: 'online booking system', definitionBody: 'z' }).definition?.heading,
'What is an online booking system?',
);
assert.equal(copy.faqHeading, 'Questions about this project tracker template');
assert.equal(copy.seoTitle, 'Free Project Tracker Template for Clients');
assert.equal(templateCopy({ name: 'Onboarding wizard', definitionBody: 'z' }).definition?.heading, 'What is an onboarding wizard?');
});

test('the definition article follows the sound, and acronyms keep their capitals', () => {
const heading = (name) => templateCopy({ name, definitionBody: 'z' }).definition?.heading;
assert.equal(heading('Online booking form'), 'What is an online booking form?');
assert.equal(heading('AI receptionist'), 'What is an AI receptionist?');
assert.equal(heading('Internal AI assistant'), 'What is an internal AI assistant?');
assert.equal(heading('PDF to digital intake'), 'What is a PDF to digital intake?');
assert.equal(heading('RSVP tracker'), 'What is an RSVP tracker?');
assert.equal(heading('RSVP-based registration form'), 'What is an RSVP-based registration form?');
assert.equal(heading('SaaS-style pricing page'), 'What is a SaaS-style pricing page?');
assert.equal(heading('Unified inbox'), 'What is a unified inbox?');
assert.equal(heading('Uninsured patient intake form'), 'What is an uninsured patient intake form?');
assert.equal(heading('Unidentified payments log'), 'What is an unidentified payments log?');
assert.equal(heading('SaaS onboarding template'), 'What is a SaaS onboarding template?');
assert.equal(heading('SAAS onboarding template'), 'What is a SAAS onboarding template?');
assert.equal(heading('One-page proposal'), 'What is a one-page proposal?');
assert.equal(heading('Hourly timesheet'), 'What is an hourly timesheet?');
assert.equal(heading('Meeting Request'), 'What is a meeting request?');
});

test('falls back field by field, never to the gallery subtitle', () => {
const copy = templateCopy({ name: 'Time tracker', longDescription: 'Track billable time per client.' });
assert.equal(copy.headline, 'Time tracker');
Expand Down
46 changes: 38 additions & 8 deletions src/lib/template-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,40 @@ function titleCase(s: string): string {
.join(" ");
}

/** "a" or "an", by the keyword's first letter. */
function article(word: string): string {
return /^[aeiou]/i.test(word) ? "an" : "a";
/** Two or more capitals ("AI", "PDF", "RSVPs") marks an acronym, kept as written. */
function isAcronym(word: string): boolean {
return /[A-Z].*[A-Z]/.test(word);
}

/** Sentence case for use mid-sentence: lowercases every word but acronyms. */
function lowerCasePhrase(s: string): string {
return s
.split(" ")
.map((word) => (isAcronym(word) ? word : word.toLowerCase()))
.join(" ");
}

/** All-caps acronyms said as a word, not letter by letter. */
const SPOKEN_ACRONYMS = new Set(["SAAS", "NASA", "NATO", "ASAP", "GIF", "JPEG", "SCUBA"]);

/**
* "a" or "an", by how the phrase sounds rather than how it's spelled: an
* acronym is read letter by letter ("an AI", "an RSVP", "a PDF"), and a few
* spellings break the vowel rule ("a unified", "a one-page", "an hourly").
*/
function article(phrase: string): string {
// The sound is set by the first part of a compound too ("RSVP-based", "one-page").
const word = phrase.split(/[\s/-]/)[0] ?? "";
const lower = word.toLowerCase();
// Only an all-caps word is read letter by letter ("AI", "PDF", "RSVPs"), and
// not even all of those; mixed case like "SaaS" is spoken as a word.
const spelledOut = /^[A-Z]{2,}s?$/.test(word) && !SPOKEN_ACRONYMS.has(word.replace(/s$/, ""));
Comment thread
vexche marked this conversation as resolved.
// Letters whose spoken name opens on a vowel: "ay", "ee", "eff", "aitch"…
if (spelledOut) return /^[AEFHILMNORSX]/.test(word) ? "an" : "a";
if (/^(hour|honest|honor|honour|heir)/.test(lower)) return "an";
// "uni-" says "yoo" (unique, unified), except the "un-" prefix (uninsured, unimportant).
if (/^(uni(?![nm]|dent)|use|usu|uti|ure|eu|ewe|one|once)/.test(lower)) return "a";
return /^[aeiou]/.test(lower) ? "an" : "a";
}

export interface TemplateCopyInput {
Expand Down Expand Up @@ -113,16 +144,15 @@ export interface TemplateCopy {
}

export function templateCopy(input: TemplateCopyInput): TemplateCopy {
const noun = input.name.toLowerCase();
const noun = lowerCasePhrase(input.name);
const keyword = input.seoKeyword ?? noun;
return {
headline: input.marketingHeadline ?? input.name,
lead: input.heroDescription,
// The question names the thing (the template's own name) so it matches the
// answer under it. The keyword is phrased for search ("project tracker
// template") and reads wrong as "What is a project tracker template?".
// The question uses the SEO keyword, so the heading carries the search term;
// a template without one falls back to its own name.
definition: input.definitionBody
? { heading: `What is ${article(noun)} ${noun}?`, body: input.definitionBody }
? { heading: `What is ${article(keyword)} ${lowerCasePhrase(keyword)}?`, body: input.definitionBody }
: null,
faqHeading: `Questions about this ${keyword}`,
ctaHeading: input.ctaHeading ?? `Start with the ${input.name} template`,
Expand Down
Loading