From 198187a9e9dbb08adf1d65bdaff1400a955b2eff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:01:37 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Template=20pages:=20"What=20is=E2=80=A6"=20?= =?UTF-8?q?heading=20uses=20the=20SEO=20keyword,=20with=20a/an=20by=20soun?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The definition heading now asks about the template's SEO keyword, falling back to its name. The article follows pronunciation rather than the first letter (an AI…, an online…, a PDF…, a unified…, an hourly…), and acronyms keep their capitals instead of being lowercased. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016GGuBqTRpJoF1ALgzbBRtg --- src/lib/template-page.test.mjs | 21 +++++++++++++++++-- src/lib/template-page.ts | 38 +++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/lib/template-page.test.mjs b/src/lib/template-page.test.mjs index b17f2ba..bcde942 100644 --- a/src/lib/template-page.test.mjs +++ b/src/lib/template-page.test.mjs @@ -46,19 +46,36 @@ 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('Unified inbox'), 'What is a unified inbox?'); + 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'); diff --git a/src/lib/template-page.ts b/src/lib/template-page.ts index d4a875f..bcc3266 100644 --- a/src/lib/template-page.ts +++ b/src/lib/template-page.ts @@ -80,9 +80,32 @@ 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(" "); +} + +/** + * "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 { + const word = phrase.split(" ")[0] ?? ""; + // Letters whose spoken name opens on a vowel: "ay", "ee", "eff", "aitch"… + if (isAcronym(word)) return /^[AEFHILMNORSX]/.test(word) ? "an" : "a"; + const lower = word.toLowerCase(); + if (/^(hour|honest|honor|honour|heir)/.test(lower)) return "an"; + if (/^(uni|use|usu|uti|ure|eu|ewe|one|once)/.test(lower)) return "a"; + return /^[aeiou]/.test(lower) ? "an" : "a"; } export interface TemplateCopyInput { @@ -113,16 +136,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`, From 10421a59368c3540cbc6f7bbe88b716e44bc06ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:06:06 +0000 Subject: [PATCH 2/3] Template headings: spell out only all-caps acronyms; keep "un-" words on "an" Addresses review: "SaaS" was read letter by letter ("an SaaS"), and the "uni" exception caught "uninsured"/"unimportant" ("a uninsured"). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016GGuBqTRpJoF1ALgzbBRtg --- src/lib/template-page.test.mjs | 4 ++++ src/lib/template-page.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/template-page.test.mjs b/src/lib/template-page.test.mjs index bcde942..5d80422 100644 --- a/src/lib/template-page.test.mjs +++ b/src/lib/template-page.test.mjs @@ -71,6 +71,10 @@ test('the definition article follows the sound, and acronyms keep their capitals 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('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?'); diff --git a/src/lib/template-page.ts b/src/lib/template-page.ts index bcc3266..6bdfd3a 100644 --- a/src/lib/template-page.ts +++ b/src/lib/template-page.ts @@ -93,6 +93,9 @@ function lowerCasePhrase(s: string): string { .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 @@ -100,11 +103,15 @@ function lowerCasePhrase(s: string): string { */ function article(phrase: string): string { const word = phrase.split(" ")[0] ?? ""; - // Letters whose spoken name opens on a vowel: "ay", "ee", "eff", "aitch"… - if (isAcronym(word)) return /^[AEFHILMNORSX]/.test(word) ? "an" : "a"; 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$/, "")); + // 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"; - if (/^(uni|use|usu|uti|ure|eu|ewe|one|once)/.test(lower)) return "a"; + // "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"; } From 0ddc3882fb030dafd6f380dbede5d61366d6bcbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:13:08 +0000 Subject: [PATCH 3/3] Template headings: pick the article from the first part of a hyphenated word "RSVP-based registration form" now gets "an", as it did before the acronym change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016GGuBqTRpJoF1ALgzbBRtg --- src/lib/template-page.test.mjs | 2 ++ src/lib/template-page.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/template-page.test.mjs b/src/lib/template-page.test.mjs index 5d80422..ea588b1 100644 --- a/src/lib/template-page.test.mjs +++ b/src/lib/template-page.test.mjs @@ -70,6 +70,8 @@ test('the definition article follows the sound, and acronyms keep their capitals 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?'); diff --git a/src/lib/template-page.ts b/src/lib/template-page.ts index 6bdfd3a..7a938c2 100644 --- a/src/lib/template-page.ts +++ b/src/lib/template-page.ts @@ -102,7 +102,8 @@ const SPOKEN_ACRONYMS = new Set(["SAAS", "NASA", "NATO", "ASAP", "GIF", "JPEG", * spellings break the vowel rule ("a unified", "a one-page", "an hourly"). */ function article(phrase: string): string { - const word = phrase.split(" ")[0] ?? ""; + // 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.