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 Feb 13, 2020
1 parent 5e413a6 commit 6063118
Show file tree
Hide file tree
Showing 20 changed files with 262 additions and 76 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);
}
}
139 changes: 139 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,139 @@
import { UrlMatchResult, UrlSegment } from '@angular/router';
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, 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 }), {})
),
});
// 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(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(
`"/Spezielles/Angebote/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('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('|'));
});
});
});
66 changes: 66 additions & 0 deletions src/app/core/routing/category/category.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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) {
return (
category &&
category
.pathCategories()
.map(cat => cat.name)
.filter(x => x)
.map(name => name.replace(/ /g, '-'))
.join('/')
);
}

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.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)
);
}
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 @@ -5,6 +5,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 { generateLocalizedCategorySlug } from 'ish-core/routing/category/category.route';

function generateProductSlug(product: ProductView) {
return product && product.name ? product.name.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/-+$/g, '') : undefined;
Expand Down Expand Up @@ -49,10 +50,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 @@ -9,6 +9,7 @@ import { debounce, distinctUntilKeyChanged, first, map, switchMap, tap } from 'r

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 @@ -55,7 +56,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
6 changes: 6 additions & 0 deletions src/app/pages/app-last-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { MetaGuard } from '@ngx-meta/core';

import { matchCategoryRoute } from 'ish-core/routing/category/category.route';
import { matchProductRoute } from 'ish-core/routing/product/product.route';

const routes: Routes = [
Expand All @@ -10,6 +11,11 @@ const routes: Routes = [
loadChildren: () => import('./product/product-page.module').then(m => m.ProductPageModule),
canActivate: [MetaGuard],
},
{
matcher: matchCategoryRoute,
loadChildren: () => import('./category/category-page.module').then(m => m.CategoryPageModule),
canActivate: [MetaGuard],
},
{ path: '**', redirectTo: '/error' },
];

Expand Down
5 changes: 0 additions & 5 deletions src/app/pages/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,6 @@ const routes: Routes = [
},
},
},
{
path: 'category',
loadChildren: () => import('./category/category-page.module').then(m => m.CategoryPageModule),
canActivate: [MetaGuard],
},
{
path: 'account',
loadChildren: () => import('./account/account-page.module').then(m => m.AccountPageModule),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MockPipe } from 'ng-mocks';

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

import { CategoryNavigationComponent } from './category-navigation.component';
Expand Down
8 changes: 3 additions & 5 deletions src/app/pages/category/category-page.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,11 @@ import { CategoryTileComponent } from './category-tile/category-tile.component';

const categoryPageRoutes: Routes = [
{
path: ':categoryUniqueId',
// compatibility to old routes
path: 'category/:categoryUniqueId',
component: CategoryPageComponent,
},
{
path: ':categoryUniqueId/product',
loadChildren: () => import('../product/product-page.module').then(m => m.ProductPageModule),
},
{ path: '**', component: CategoryPageComponent },
];

@NgModule({
Expand Down
Loading

0 comments on commit 6063118

Please sign in to comment.