Skip to content

Commit

Permalink
Improvements for canonical link generation
Browse files Browse the repository at this point in the history
  • Loading branch information
AlemTuzlak committed Jul 26, 2024
1 parent b5f06db commit a899965
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 19 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ are actually used in your bundle as mentioned above. Now we will go over each su

The canonical link is a link that tells search engines that a certain URL represents the master copy of a page. This is useful for SEO because it helps search engines avoid duplicate content issues and tell it for alternative languages/content.

The transformer function will return the canonical url and the current alternative, the alternative can be a string, object or anything else that you can pass to the transformer.

```typescript
import { generateCanonicalLinks } from '@forge42/seo-tools/canonical';

const canonicalLinks = generateCanonicalLinks({
// Used to generate the final url, it passes your alternatives, url and domain to the function for you to create whatever link you need
urlTransformer: ({ url, domain, alternative }) => `${domain}/${url}?lng=${alternative}`,
urlTransformer: ({ url, domain, alternative, canonicalUrl }) => `${domain}/${url}?lng=${alternative}`,
// Used to generate the final attributes
altAttributesTransformer: ({ url, domain, alternative }) => attributes,
altAttributesTransformer: ({ url, domain, alternative, canonicalUrl }) => attributes,
// This takes a generic type and returns it in your transformers
alternatives: ["de", "es"],
domain: "https://example.com",
url: "current-url",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@forge42/seo-tools",
"version": "1.1.1",
"version": "1.2.0",
"private": false,
"keywords": ["seo", "remix-seo", "seo-tools", "structured-data", "sitemap", "robots", "canonical", "seo-alternate"],
"description": "Set of helpers designed to help you create, maintain and develop your SEO",
Expand Down
44 changes: 44 additions & 0 deletions src/canonical.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,50 @@ describe("generateCanonicalLinks", () => {
])
})

it("should work when altenratives are an object array", () => {
const canonicalLinks = generateCanonicalLinks({
url: "/page",
domain: "https://example.com",
alternatives: [{ lang: "en" }, { lang: "es" }],
urlTransformer: ({ url, alternative, domain }) => `${domain}/${alternative.lang}${url}`,
})
expect(canonicalLinks).toEqual([
{
tagName: "link",
rel: "canonical",
href: "https://example.com/page",
},
{
tagName: "link",
rel: "alternate",
href: "https://example.com/en/page",
},
{
tagName: "link",
rel: "alternate",
href: "https://example.com/es/page",
},
])
})

it("should allow you to remove duplicate urls by returning null if they match with the canonical url", () => {
const canonicalLinks = generateCanonicalLinks({
url: "/page",
domain: "https://example.com",
alternatives: ["en", "es"],
urlTransformer: ({ url, alternative, domain, canonicalUrl }) => {
return `${domain}${url}` === canonicalUrl ? null : `${domain}/${alternative}${url}`
},
})
expect(canonicalLinks).toEqual([
{
tagName: "link",
rel: "canonical",
href: "https://example.com/page",
},
])
})

it("should return links properly as an array of objects with alternative attributes", () => {
const canonicalLinks = generateCanonicalLinks({
url: "/page",
Expand Down
38 changes: 22 additions & 16 deletions src/canonical.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface CanonicalLinkOptions {
export interface CanonicalLinkOptions<U> {
/**
* Transformer function that generates the alternative url for the link tag by looping through the alternatives
* @param params Object containing the url, alternative and domain
Expand All @@ -9,7 +9,7 @@ export interface CanonicalLinkOptions {
* }
* ```
*/
urlTransformer: (params: { url: string; alternative: string; domain: string }) => string
urlTransformer: (params: { url: string; alternative: U; domain: string; canonicalUrl: string }) => string | null
/**
* Transformer function that generates the attributes for the alternative link tag
* @param params Object containing the url, alternative and domain
Expand All @@ -24,15 +24,15 @@ export interface CanonicalLinkOptions {
* }
* ```
*/
altAttributesTransformer?: (params: { url: string; alternative: string; domain: string }) => Omit<
altAttributesTransformer?: (params: { url: string; alternative: U; domain: string }) => Omit<
CanonicalLink,
"tagName" | "rel" | "href"
>
/**
* Array of alternative languages/urls or whatever you want to generate the alternative links for
* @example ["en", "bs"]
*/
alternatives: string[]
alternatives: U[]
/**
* The domain of the website
* @example "https://example.com"
Expand Down Expand Up @@ -64,44 +64,50 @@ const convertObjectToString = (obj: Record<string, string>) => {
.join(" ")
}

function isHrefDefined(obj: Record<string, string | null>): obj is CanonicalLink {
return obj.href !== null
}
/**
* Method used to generate the canonical and alternative links for a page
* @param options CanonicalLinkOptions - Object containing the options for generating the canonical links
* @param asJson Whether to return the canonical links as an array of objects or a string
* @returns Returns an array of objects or a string containing the canonical and alternative links
*/
export const generateCanonicalLinks = <T extends boolean>(
options: CanonicalLinkOptions,
export const generateCanonicalLinks = <U, T extends boolean>(
options: CanonicalLinkOptions<U>,
asJson: T = true as T
): T extends true ? CanonicalLink[] : string => {
const { urlTransformer, alternatives, domain, canonicalAttributes, url, altAttributesTransformer } = options
const generatedCanonicalAttributes = canonicalAttributes ? convertObjectToString(canonicalAttributes) : ""
const canonicalUrl = `${domain + url}`
if (asJson) {
return [
{
tagName: "link",
rel: "canonical",
href: `${domain + url}`,
href: canonicalUrl,
...(canonicalAttributes ? canonicalAttributes : {}),
},
...alternatives.map((alternative) => ({
tagName: "link" as const,
rel: "alternate",
href: urlTransformer({ url, alternative, domain }),
...(altAttributesTransformer ? altAttributesTransformer({ url, alternative, domain }) : {}),
})),
...alternatives
.map((alternative) => ({
tagName: "link" as const,
rel: "alternate",
href: urlTransformer({ url, alternative, domain, canonicalUrl }),
...(altAttributesTransformer ? altAttributesTransformer({ url, alternative, domain }) : {}),
}))
.filter(isHrefDefined),

// biome-ignore lint/suspicious/noExplicitAny: TODO: fix this
// biome-ignore lint/suspicious/noExplicitAny: we use satisfies to type-safe it and as any cast to allow for conditional return type
] satisfies CanonicalLink[] as any
}
return [
`<link rel="canonical" href="${domain + url}" ${generatedCanonicalAttributes}>`,
...alternatives.map(
(alternative) =>
`<link rel="alternate" href="${urlTransformer({ url, alternative, domain })}" ${
`<link rel="alternate" href="${urlTransformer({ url, alternative, domain, canonicalUrl })}" ${
altAttributesTransformer ? convertObjectToString(altAttributesTransformer({ url, alternative, domain })) : ""
} >`
),
// biome-ignore lint/suspicious/noExplicitAny: TODO: fix this
// biome-ignore lint/suspicious/noExplicitAny: we use satisfies to type-safe it and as any cast to allow for conditional return type
].join("\n") satisfies string as any
}

0 comments on commit a899965

Please sign in to comment.