fix(seo): descriptions were two sentences run together on all eleven detail pages
Live until now, on every service and industry page: "...seamless integration Delivered by Queue North, a veteran-owned..." "...for medical providers Queue North delivers phone, contact center..." clampDescription joined its fragments with a space, and no shortDesc in services.js or industries.js ends in a full stop. Nothing added one. The description is the first thing a searcher reads, and it read as a run-on on eleven of the eighteen pages. buildDescription replaces it and joins fragments AS SENTENCES. It also takes an `approved` description, which it emits verbatim: never joined, never clamped. Levi's approved copy for the two long-form pages is 164 and 191 characters, and the old 158-character clamp would have cut his sentences off with an ellipsis. ServiceDetail reads it from `page.seo`, which the content commit fills in. ServiceDetail and IndustryDetail now build their canonical from SITE_URL rather than repeating the origin. The three files release.sh greps for that literal are untouched, so the origin guard still has three copies to compare. Verified against a build, page by page: all 11 detail descriptions gained exactly a full stop and changed in no other way, and no other page's description moved. Closes #234. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aab0104b01
commit
fef8b0718c
|
|
@ -13,13 +13,35 @@ export const websiteLd = {
|
||||||
|
|
||||||
const MAX_DESCRIPTION = 158
|
const MAX_DESCRIPTION = 158
|
||||||
|
|
||||||
|
// A fragment that already ends a sentence keeps its punctuation, including when
|
||||||
|
// it closes with a quote or a bracket. One that does not gets a full stop.
|
||||||
|
const asSentence = (text) => (/[.!?…]["'”’)\]]?$/.test(text) ? text : `${text}.`)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Joins description fragments and trims to what search engines actually display,
|
* The description a page emits, from one of two sources.
|
||||||
* cutting on a word boundary rather than mid-word.
|
*
|
||||||
* @param {...string} parts
|
* `approved` is owner-approved text. It is emitted exactly as written: never
|
||||||
|
* joined to anything, never clamped. Levi's Unified Communications and Contact
|
||||||
|
* Center descriptions are 164 and 191 characters, and clamping them would have
|
||||||
|
* shipped his approved copy cut off mid-sentence with an ellipsis.
|
||||||
|
*
|
||||||
|
* `parts` are fragments this site composes itself, and they are joined AS
|
||||||
|
* SENTENCES. Joining them with a space alone is what produced "...seamless
|
||||||
|
* integration Delivered by Queue North..." on all eleven detail pages: no
|
||||||
|
* shortDesc in services.js or industries.js ends in a full stop, and nothing
|
||||||
|
* added one. The description is the first thing a searcher reads.
|
||||||
|
*
|
||||||
|
* @param {{approved?: string, parts?: string[]}} source
|
||||||
*/
|
*/
|
||||||
export const clampDescription = (...parts) => {
|
export const buildDescription = ({ approved, parts = [] }) => {
|
||||||
const text = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
if (approved) return approved
|
||||||
|
|
||||||
|
const text = parts
|
||||||
|
.map((part) => part?.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(asSentence)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
if (text.length <= MAX_DESCRIPTION) return text
|
if (text.length <= MAX_DESCRIPTION) return text
|
||||||
|
|
||||||
const cut = text.slice(0, MAX_DESCRIPTION)
|
const cut = text.slice(0, MAX_DESCRIPTION)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
import { buildBreadcrumbLd, clampDescription } from '@/lib/seo'
|
import { SITE_URL, buildBreadcrumbLd, buildDescription } from '@/lib/seo'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { industries } from '@/data/industries'
|
import { industries } from '@/data/industries'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
@ -33,11 +33,13 @@ const IndustryDetail = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const industryTitle = `${industry.name} Communications & IT | Queue North`
|
const industryTitle = `${industry.name} Communications & IT | Queue North`
|
||||||
const industryDesc = clampDescription(
|
const industryDesc = buildDescription({
|
||||||
industry.shortDesc,
|
parts: [
|
||||||
`Queue North delivers phone, contact center, network, and IT support for ${industry.name.toLowerCase()} organizations.`,
|
industry.shortDesc,
|
||||||
)
|
`Queue North delivers phone, contact center, network, and IT support for ${industry.name.toLowerCase()} organizations.`,
|
||||||
const industryUrl = `https://queuenorth.com/industries/${industry.id}`
|
],
|
||||||
|
})
|
||||||
|
const industryUrl = `${SITE_URL}/industries/${industry.id}`
|
||||||
const industryBreadcrumbLd = buildBreadcrumbLd([
|
const industryBreadcrumbLd = buildBreadcrumbLd([
|
||||||
{ name: 'Industries', path: '/industries' },
|
{ name: 'Industries', path: '/industries' },
|
||||||
{ name: industry.name, path: `/industries/${industry.id}` },
|
{ name: industry.name, path: `/industries/${industry.id}` },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
import { buildBreadcrumbLd, clampDescription } from '@/lib/seo'
|
import { SITE_URL, buildBreadcrumbLd, buildDescription } from '@/lib/seo'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { services } from '@/data/services'
|
import { services } from '@/data/services'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
@ -38,12 +38,14 @@ const ServiceDetail = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceTitle = `${service.name} | Queue North`
|
// A service with owner-approved copy carries its own title and description in
|
||||||
const serviceDesc = clampDescription(
|
// `page.seo`, and both are emitted verbatim. Everything else composes.
|
||||||
service.shortDesc,
|
const serviceTitle = service.page?.seo?.title ?? `${service.name} | Queue North`
|
||||||
'Delivered by Queue North, a veteran-owned 8x8 and Cisco Certified Partner.',
|
const serviceDesc = buildDescription({
|
||||||
)
|
approved: service.page?.seo?.description,
|
||||||
const serviceUrl = `https://queuenorth.com/services/${service.id}`
|
parts: [service.shortDesc, 'Delivered by Queue North, a veteran-owned 8x8 and Cisco Certified Partner.'],
|
||||||
|
})
|
||||||
|
const serviceUrl = `${SITE_URL}/services/${service.id}`
|
||||||
const serviceBreadcrumbLd = buildBreadcrumbLd([
|
const serviceBreadcrumbLd = buildBreadcrumbLd([
|
||||||
{ name: 'Services', path: '/services' },
|
{ name: 'Services', path: '/services' },
|
||||||
{ name: service.name, path: `/services/${service.id}` },
|
{ name: service.name, path: `/services/${service.id}` },
|
||||||
|
|
@ -52,7 +54,7 @@ const ServiceDetail = () => {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Service',
|
'@type': 'Service',
|
||||||
name: service.name,
|
name: service.name,
|
||||||
description: service.shortDesc,
|
description: service.page?.seo?.description ?? service.shortDesc,
|
||||||
provider: {
|
provider: {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Queue North Technologies',
|
name: 'Queue North Technologies',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue