-
Notifications
You must be signed in to change notification settings - Fork 86
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: SEO friendly localized URLs for category and product list pages (…
…#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
Showing
20 changed files
with
280 additions
and
76 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
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, 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( | ||
`"/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('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('|')); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.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) | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.