Skip to content

Commit

Permalink
feat: SEO friendly localized URLs for product detail pages (#110)
Browse files Browse the repository at this point in the history
- moved ProductRoutePipe to core/routing/product
- match incoming URLs for products with Angular UrlMatcher and RegExp pattern
- improved typing of ProductRoutePipe

BREAKING CHANGE: product URLs no longer match static pattern .*/product/<sku> but can instead be localized and customized for any pattern
  • Loading branch information
dhhyi committed Feb 12, 2020
1 parent dd39f81 commit d99c98b
Show file tree
Hide file tree
Showing 24 changed files with 399 additions and 135 deletions.
2 changes: 1 addition & 1 deletion src/app/core/pipes.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { CategoryRoutePipe } from './pipes/category-route.pipe';
import { DatePipe } from './pipes/date.pipe';
import { HighlightPipe } from './pipes/highlight.pipe';
import { MakeHrefPipe } from './pipes/make-href.pipe';
import { ProductRoutePipe } from './pipes/product-route.pipe';
import { SafeHtmlPipe } from './pipes/safe-html.pipe';
import { SanitizePipe } from './pipes/sanitize.pipe';
import { ProductRoutePipe } from './routing/product/product-route.pipe';

const pipes = [
AttributeToStringPipe,
Expand Down
50 changes: 0 additions & 50 deletions src/app/core/pipes/product-route.pipe.spec.ts

This file was deleted.

40 changes: 0 additions & 40 deletions src/app/core/pipes/product-route.pipe.ts

This file was deleted.

12 changes: 12 additions & 0 deletions src/app/core/routing/product/product-route.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';

import { CategoryView } from 'ish-core/models/category-view/category-view.model';
import { ProductView } from 'ish-core/models/product-view/product-view.model';
import { generateProductUrl } from 'ish-core/routing/product/product.route';

@Pipe({ name: 'ishProductRoute', pure: true })
export class ProductRoutePipe implements PipeTransform {
transform(product: ProductView, category?: CategoryView): string {
return generateProductUrl(product, category);
}
}
263 changes: 263 additions & 0 deletions src/app/core/routing/product/product.route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { UrlMatchResult, UrlSegment } from '@angular/router';

import { createCategoryView } from 'ish-core/models/category-view/category-view.model';
import { Category } from 'ish-core/models/category/category.model';
import { createProductView } from 'ish-core/models/product-view/product-view.model';
import { Product } from 'ish-core/models/product/product.model';
import { categoryTree } from 'ish-core/utils/dev/test-data-utils';

import { generateProductUrl, matchProductRoute } from './product.route';

describe('Product Route', () => {
const specials = { categoryPath: ['Specials'], uniqueId: 'Specials', name: 'Spezielles' } as Category;
const topSeller = {
categoryPath: ['Specials', 'Specials.TopSeller'],
uniqueId: 'Specials.TopSeller',
name: 'Angebote',
} as Category;
const limitedOffer = {
categoryPath: ['Specials', 'Specials.TopSeller', 'Specials.TopSeller.LimitedOffer'],
uniqueId: 'Specials.TopSeller.LimitedOffer',
name: 'Black Friday',
} as Category;

expect.addSnapshotSerializer({
test: val => val && val.consumed && val.posParams,
print: (val: UrlMatchResult, serialize) =>
serialize(
Object.keys(val.posParams)
.map(key => ({ [key]: val.posParams[key].path }))
.reduce((acc, v) => ({ ...acc, ...v }), {})
),
});
// tslint:disable-next-line: no-suspicious-variable-init-in-tests
const wrap = generated =>
generated
.split('/')
.filter(x => x)
.map(path => new UrlSegment(path, {}));

describe('without anything', () => {
it('should be created', () => {
expect(generateProductUrl(undefined)).toMatchInlineSnapshot(`"/"`);
expect(generateProductUrl(undefined, undefined)).toMatchInlineSnapshot(`"/"`);
});

it('should not be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(undefined)))).toMatchInlineSnapshot(`undefined`);
});
});

describe('without category', () => {
describe('without product name', () => {
const product = createProductView({ sku: 'A' } as Product, categoryTree());
it('should create simple link when just sku is supplied', () => {
expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/skuA"`);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(`
Object {
"sku": "A",
}
`);
});
});

describe('with product name', () => {
const product = createProductView({ sku: 'A', name: 'some example name' } as Product, categoryTree());

it('should include slug when product has a name', () => {
expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/some-example-name-skuA"`);
});

it('should include filtered slug when product has a name with special characters', () => {
const product2 = { ...product, name: 'name & speci@l char$' };
expect(generateProductUrl(product2)).toMatchInlineSnapshot(`"/name-speci-l-char-skuA"`);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(`
Object {
"sku": "A",
}
`);
});
});
});

describe('with top level category', () => {
const categories = categoryTree([specials]);
const category = createCategoryView(categories, specials.uniqueId);

describe('as context', () => {
describe('without product name', () => {
const product = createProductView({ sku: 'A' } as Product, categories);

it('should be created', () => {
expect(generateProductUrl(product, category)).toMatchInlineSnapshot(`"/Spezielles/skuA-catSpecials"`);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials",
"sku": "A",
}
`);
});
});

describe('with product name', () => {
const product = createProductView({ sku: 'A', name: 'Das neue Surface Pro 7' } as Product, categories);

it('should be created', () => {
expect(generateProductUrl(product, category)).toMatchInlineSnapshot(
`"/Spezielles/Das-neue-Surface-Pro-7-skuA-catSpecials"`
);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials",
"sku": "A",
}
`);
});
});
});

describe('as default category', () => {
describe('without product name', () => {
const product = createProductView({ sku: 'A', defaultCategoryId: specials.uniqueId } as Product, categories);

it('should be created', () => {
expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/Spezielles/skuA-catSpecials"`);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials",
"sku": "A",
}
`);
});
});

describe('with product name', () => {
const product = createProductView(
{ sku: 'A', name: 'Das neue Surface Pro 7', defaultCategoryId: specials.uniqueId } as Product,
categories
);

it('should be created', () => {
expect(generateProductUrl(product)).toMatchInlineSnapshot(
`"/Spezielles/Das-neue-Surface-Pro-7-skuA-catSpecials"`
);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials",
"sku": "A",
}
`);
});
});
});
});

describe('with deep category', () => {
const categories = categoryTree([specials, topSeller, limitedOffer]);
const category = createCategoryView(categories, limitedOffer.uniqueId);

describe('as context', () => {
describe('without product name', () => {
const product = createProductView({ sku: 'A' } as Product, categories);

it('should be created', () => {
expect(generateProductUrl(product, category)).toMatchInlineSnapshot(
`"/Spezielles/Angebote/Black-Friday/skuA-catSpecials.TopSeller.LimitedOffer"`
);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials.TopSeller.LimitedOffer",
"sku": "A",
}
`);
});
});

describe('with product name', () => {
const product = createProductView({ sku: 'A', name: 'Das neue Surface Pro 7' } as Product, categories);

it('should be created', () => {
expect(generateProductUrl(product, category)).toMatchInlineSnapshot(
`"/Spezielles/Angebote/Black-Friday/Das-neue-Surface-Pro-7-skuA-catSpecials.TopSeller.LimitedOffer"`
);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials.TopSeller.LimitedOffer",
"sku": "A",
}
`);
});
});
});

describe('as default category', () => {
describe('without product name', () => {
const product = createProductView(
{ sku: 'A', defaultCategoryId: limitedOffer.uniqueId } as Product,
categories
);

it('should be created', () => {
expect(generateProductUrl(product)).toMatchInlineSnapshot(
`"/Spezielles/Angebote/Black-Friday/skuA-catSpecials.TopSeller.LimitedOffer"`
);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials.TopSeller.LimitedOffer",
"sku": "A",
}
`);
});
});

describe('with product name', () => {
const product = createProductView(
{ sku: 'A', name: 'Das neue Surface Pro 7', defaultCategoryId: limitedOffer.uniqueId } as Product,
categories
);

it('should be created', () => {
expect(generateProductUrl(product)).toMatchInlineSnapshot(
`"/Spezielles/Angebote/Black-Friday/Das-neue-Surface-Pro-7-skuA-catSpecials.TopSeller.LimitedOffer"`
);
});

it('should be a match for matcher', () => {
expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials.TopSeller.LimitedOffer",
"sku": "A",
}
`);
});
});
});
});
});
Loading

0 comments on commit d99c98b

Please sign in to comment.