Skip to content

Commit

Permalink
feat: SEO friendly localized URLs for category and product list pages (
Browse files Browse the repository at this point in the history
…#110)

- moved CategoryRoutePipe to core/routing/category
- match incoming URLs for categories with Angular UrlMatcher and RegExp pattern
- improved typing of CategoryRoutePipe
  • Loading branch information
dhhyi committed Mar 2, 2020
1 parent 91767f3 commit 4e3169e
Show file tree
Hide file tree
Showing 21 changed files with 291 additions and 79 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 @@ -2,12 +2,12 @@ import { ModuleWithProviders, NgModule } from '@angular/core';

import { AttributeToStringPipe } from './models/attribute/attribute.pipe';
import { PricePipe } from './models/price/price.pipe';
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 { SafeHtmlPipe } from './pipes/safe-html.pipe';
import { SanitizePipe } from './pipes/sanitize.pipe';
import { CategoryRoutePipe } from './routing/category/category-route.pipe';
import { ProductRoutePipe } from './routing/product/product-route.pipe';

const pipes = [
Expand Down
26 changes: 0 additions & 26 deletions src/app/core/pipes/category-route.pipe.spec.ts

This file was deleted.

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

This file was deleted.

12 changes: 12 additions & 0 deletions src/app/core/routing/category/category-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 { generateCategoryUrl } from './category.route';

@Pipe({ name: 'ishCategoryRoute', pure: true })
export class CategoryRoutePipe implements PipeTransform {
transform(category: CategoryView): string {
return generateCategoryUrl(category);
}
}
176 changes: 176 additions & 0 deletions src/app/core/routing/category/category.route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { TestBed } from '@angular/core/testing';
import { Router, UrlMatchResult, UrlSegment } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { cold } from 'jest-marbles';
import { RouteNavigation } from 'ngrx-router';
import { of } from 'rxjs';

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

import {
generateCategoryUrl,
generateLocalizedCategorySlug,
matchCategoryRoute,
ofCategoryRoute,
} from './category.route';

describe('Category 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 }), {})
),
});

let wrap: (url: string) => UrlSegment[];

beforeEach(() => {
TestBed.configureTestingModule({ imports: [RouterTestingModule] });
const router: Router = TestBed.get(Router);
wrap = url => {
const primary = router.parseUrl(url).root.children.primary;
return primary ? primary.segments : [];
};
});

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

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

describe('with top level category without name', () => {
const category = createCategoryView(categoryTree([{ ...specials, name: undefined }]), specials.uniqueId);

it('should be created', () => {
expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/catSpecials"`);
});

it('should not be a match for matcher', () => {
expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials",
}
`);
});
});

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

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

it('should not be a match for matcher', () => {
expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials",
}
`);
});
});

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

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

it('should not be a match for matcher', () => {
expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials.TopSeller.LimitedOffer",
}
`);
});
});

describe('compatibility', () => {
it('should detect category route without product after it', () => {
expect(matchCategoryRoute(wrap('/category/123'))).toHaveProperty('consumed');
});

it('should not detect category route with product after it', () => {
expect(matchCategoryRoute(wrap('/category/123/product/123'))).toBeUndefined();
});
});

describe('additional URL params', () => {
it('should ignore additional URL params when supplied', () => {
const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId);
expect(matchCategoryRoute(wrap(generateCategoryUrl(category) + ';lang=de_DE;redirect=1'))).toMatchInlineSnapshot(`
Object {
"categoryUniqueId": "Specials.TopSeller.LimitedOffer",
}
`);
});
});

describe('ofCategoryRoute', () => {
it('should detect category route when categoryUniqueId is a param', () => {
const stream$ = of(new RouteNavigation({ path: 'any', params: { categoryUniqueId: '123' } }));
expect(stream$.pipe(ofCategoryRoute())).toBeObservable(
cold('(a|)', {
a: new RouteNavigation({
params: {
categoryUniqueId: '123',
},
path: 'any',
url: '/any',
}),
})
);
});

it('should not detect category route when categoryUniqueId is missing', () => {
const stream$ = of(new RouteNavigation({ path: 'any' }));

expect(stream$.pipe(ofCategoryRoute())).toBeObservable(cold('|'));
});

it('should not detect category route when categoryUniqueId and sku are params', () => {
const stream$ = of(new RouteNavigation({ path: 'any', params: { categoryUniqueId: '123', sku: '123' } }));

expect(stream$.pipe(ofCategoryRoute())).toBeObservable(cold('|'));
});
});

describe('generateLocalizedCategorySlug', () => {
it('should generate slug for top level category', () => {
const category = createCategoryView(categoryTree([specials]), specials.uniqueId);
expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"Spezielles"`);
});

it('should generate slug for deep category', () => {
const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId);
expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"Black-Friday"`);
});

it('should return empty string when category is unavailable', () => {
expect(generateLocalizedCategorySlug(undefined)).toMatchInlineSnapshot(`""`);
});
});
});
62 changes: 62 additions & 0 deletions src/app/core/routing/category/category.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { UrlMatchResult, UrlSegment } from '@angular/router';
import { RouteNavigation, ofRoute } from 'ngrx-router';
import { MonoTypeOperatorFunction } from 'rxjs';
import { filter } from 'rxjs/operators';

import { CategoryView } from 'ish-core/models/category-view/category-view.model';

export function generateLocalizedCategorySlug(category: CategoryView) {
if (!category || !category.categoryPath.length) {
return '';
}
const lastCat = category.pathCategories()[category.categoryPath.length - 1].name;
return lastCat ? lastCat.replace(/ /g, '-') : '';
}

const categoryRouteFormat = new RegExp('^/(?!category/.*$)(.*)cat(.*)$');

export function matchCategoryRoute(segments: UrlSegment[]): UrlMatchResult {
// compatibility to old routes
if (segments && segments.length === 2 && segments[0].path === 'category') {
return { consumed: [] };
}

const url = '/' + segments.map(s => s.path).join('/');
if (categoryRouteFormat.test(url)) {
const match = categoryRouteFormat.exec(url);
const posParams: { [id: string]: UrlSegment } = {};
if (match[2]) {
posParams.categoryUniqueId = new UrlSegment(match[2], {});
}
return {
consumed: [],
posParams,
};
}
return;
}

export function generateCategoryUrl(category: CategoryView): string {
if (!category) {
return '/';
}
let route = '/';

route += generateLocalizedCategorySlug(category);

if (route !== '/') {
route += '-';
}

route += `cat${category.uniqueId}`;

return route;
}

export function ofCategoryRoute(): MonoTypeOperatorFunction<RouteNavigation> {
return source$ =>
source$.pipe(
ofRoute(),
filter(action => action.payload.params && action.payload.params.categoryUniqueId && !action.payload.params.sku)
);
}
8 changes: 4 additions & 4 deletions src/app/core/routing/product/product.route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ describe('Product Route', () => {

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

Expand All @@ -226,7 +226,7 @@ describe('Product Route', () => {

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

Expand All @@ -250,7 +250,7 @@ describe('Product Route', () => {

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

Expand All @@ -272,7 +272,7 @@ describe('Product Route', () => {

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

Expand Down
6 changes: 2 additions & 4 deletions src/app/core/routing/product/product.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { filter } from 'rxjs/operators';
import { CategoryView } from 'ish-core/models/category-view/category-view.model';
import { ProductView } from 'ish-core/models/product-view/product-view.model';
import { ProductHelper } from 'ish-core/models/product/product.model';
import { generateLocalizedCategorySlug } from 'ish-core/routing/category/category.route';

function generateProductSlug(product: ProductView) {
if (!product || !product.name) {
Expand Down Expand Up @@ -64,10 +65,7 @@ export function generateProductUrl(product: ProductView, category?: CategoryView
let route = '/';

if (contextCategory) {
route += contextCategory
.pathCategories()
.map(cat => cat.name.replace(/ /g, '-'))
.join('/');
route += generateLocalizedCategorySlug(contextCategory);
route += '/';
}

Expand Down
6 changes: 4 additions & 2 deletions src/app/core/store/shopping/categories/categories.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {

import { MAIN_NAVIGATION_MAX_SUB_CATEGORIES_DEPTH } from 'ish-core/configurations/injection-keys';
import { CategoryHelper } from 'ish-core/models/category/category.model';
import { ofCategoryRoute } from 'ish-core/routing/category/category.route';
import { CategoriesService } from 'ish-core/services/categories/categories.service';
import { LoadMoreProducts } from 'ish-core/store/shopping/product-listing';
import { HttpStatusCodeService } from 'ish-core/utils/http-status-code/http-status-code.service';
Expand Down Expand Up @@ -135,8 +136,9 @@ export class CategoriesEffects {

@Effect()
productOrCategoryChanged$ = this.actions$.pipe(
ofRoute('category/:categoryUniqueId'),
mapToParam<string>('categoryUniqueId'),
ofCategoryRoute(),
mapToParam('sku'),
whenFalsy(),
switchMap(() => this.store.pipe(select(selectors.getSelectedCategory))),
whenTruthy(),
filter(cat => cat.hasOnlineProducts),
Expand Down
3 changes: 2 additions & 1 deletion src/app/extensions/seo/store/seo/seo.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { debounce, distinctUntilKeyChanged, filter, first, map, switchMap, tap }

import { ProductHelper } from 'ish-core/models/product/product.helper';
import { SeoAttributes } from 'ish-core/models/seo-attribute/seo-attribute.model';
import { ofCategoryRoute } from 'ish-core/routing/category/category.route';
import { generateProductUrl, ofProductRoute } from 'ish-core/routing/product/product.route';
import { getSelectedContentPage } from 'ish-core/store/content/pages';
import { CategoriesActionTypes } from 'ish-core/store/shopping/categories';
Expand Down Expand Up @@ -75,7 +76,7 @@ export class SeoEffects {

@Effect()
seoCategory$ = this.actions$.pipe(
ofRoute('category/:categoryUniqueId'),
ofCategoryRoute(),
debounce(() => this.actions$.pipe(ofType(CategoriesActionTypes.SelectedCategoryAvailable))),
switchMap(() =>
this.store.pipe(
Expand Down
Loading

0 comments on commit 4e3169e

Please sign in to comment.