Skip to content

Commit

Permalink
feat(seo): optimized content page URLs with full content page path (#…
Browse files Browse the repository at this point in the history
…1161)

* introduced content page route generation similar to categoryies
* added content page id marker 'pg'

BREAKING CHANGES: Changed content page routes/URLs (see [Migrations / 2.4 to 3.0](https://github.com/intershop/intershop-pwa/blob/develop/docs/guides/migrations.md#24-to-30) for more details).
  • Loading branch information
Eisie96 authored and shauke committed Jul 19, 2022
1 parent 0252857 commit 7a71372
Show file tree
Hide file tree
Showing 16 changed files with 571 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ContentPageTree, ContentPageTreeElement } from 'ish-core/models/content
export interface ContentPageTreeView extends ContentPageTreeElement {
parent: string;
children: ContentPageTreeView[];
pathElements: ContentPageTreeElement[];
}

/**
Expand Down Expand Up @@ -38,10 +39,12 @@ function getContentPageTreeElements(
}

const parent = tree.nodes[elementId].path[tree.nodes[elementId].path.length - 2];

treeElements.push({
...tree.nodes[elementId],
parent,
children: [],
pathElements: tree.nodes[elementId].path.map(p => tree.nodes[p]),
});

return treeElements;
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/pipes.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { MakeHrefPipe } from './pipes/make-href.pipe';
import { SanitizePipe } from './pipes/sanitize.pipe';
import { ServerSettingPipe } from './pipes/server-setting.pipe';
import { CategoryRoutePipe } from './routing/category/category-route.pipe';
import { ContentPageRoutePipe } from './routing/content-page/content-page-route.pipe';
import { ProductRoutePipe } from './routing/product/product-route.pipe';

const pipes = [
AttributeToStringPipe,
CategoryRoutePipe,
ContentPageRoutePipe,
DatePipe,
FeatureTogglePipe,
HighlightPipe,
Expand Down
12 changes: 12 additions & 0 deletions src/app/core/routing/content-page/content-page-route.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';

import { ContentPageTreeView } from 'ish-core/models/content-page-tree-view/content-page-tree-view.model';

import { generateContentPageUrl } from './content-page.route';

@Pipe({ name: 'ishContentPageRoute', pure: true })
export class ContentPageRoutePipe implements PipeTransform {
transform(page: ContentPageTreeView): string {
return generateContentPageUrl(page);
}
}
112 changes: 112 additions & 0 deletions src/app/core/routing/content-page/content-page-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { TestBed } from '@angular/core/testing';
import { Router, UrlMatchResult, UrlSegment } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';

import { createContentPageTreeView } from 'ish-core/models/content-page-tree-view/content-page-tree-view.model';
import { ContentPageTreeHelper } from 'ish-core/models/content-page-tree/content-page-tree.helper';
import { ContentPageTreeElement } from 'ish-core/models/content-page-tree/content-page-tree.model';

import { generateContentPageUrl, matchContentRoute } from './content-page.route';

describe('Content Page Route', () => {
const topElement: ContentPageTreeElement = { contentPageId: 'top', name: 'top layer', path: ['top'] };
const secondElement: ContentPageTreeElement = {
contentPageId: 'second',
name: 'second layer',
path: ['top', 'second'],
};
const bottomElement: ContentPageTreeElement = {
contentPageId: 'bottom',
name: 'bottom layer',
path: ['top', 'second', 'bottom'],
};

expect.addSnapshotSerializer({
test: 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 = TestBed.inject(Router);
wrap = url => {
const primary = router.parseUrl(url).root.children.primary;
return primary ? primary.segments : [];
};
});

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

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

describe('with top level content page without name', () => {
const topLevelView = createContentPageTreeView(
ContentPageTreeHelper.single({ ...topElement, name: undefined }),
'top',
'top'
);

it('should be created', () => {
expect(generateContentPageUrl(topLevelView)).toMatchInlineSnapshot(`"/pgtop"`);
});

it('should be a match for matcher', () => {
expect(matchContentRoute(wrap(generateContentPageUrl(topLevelView)))).toMatchInlineSnapshot(`
Object {
"contentPageId": "top",
}
`);
});
});

describe('with top level content page', () => {
const topLevelView = createContentPageTreeView(ContentPageTreeHelper.single(topElement), 'top', 'top');

it('should be created', () => {
expect(generateContentPageUrl(topLevelView)).toMatchInlineSnapshot(`"/top-layer-pgtop"`);
});

it('should not be a match for matcher', () => {
expect(matchContentRoute(wrap(generateContentPageUrl(topLevelView)))).toMatchInlineSnapshot(`
Object {
"contentPageId": "top",
}
`);
});
});

describe('with deep content page hierachy', () => {
const topLevelTree = ContentPageTreeHelper.single(topElement);
const secondLevelTree = ContentPageTreeHelper.add(topLevelTree, secondElement);
const completeTree = ContentPageTreeHelper.add(secondLevelTree, bottomElement);

const bottomLevelView = createContentPageTreeView(completeTree, 'bottom', 'bottom');

it('should be created', () => {
expect(generateContentPageUrl(bottomLevelView)).toMatchInlineSnapshot(
`"/top-layer/second-layer/bottom-layer-pgbottom"`
);
});

it('should not be a match for matcher', () => {
expect(matchContentRoute(wrap(generateContentPageUrl(bottomLevelView)))).toMatchInlineSnapshot(`
Object {
"contentPageId": "bottom",
}
`);
});
});
});
79 changes: 79 additions & 0 deletions src/app/core/routing/content-page/content-page.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { UrlMatchResult, UrlSegment } from '@angular/router';
import { MonoTypeOperatorFunction, filter } from 'rxjs';

import { ContentPageTreeView } from 'ish-core/models/content-page-tree-view/content-page-tree-view.model';
import { ContentPageTreeElement } from 'ish-core/models/content-page-tree/content-page-tree.model';
import { CoreState } from 'ish-core/store/core/core-store';
import { selectRouteParam } from 'ish-core/store/core/router';
import { sanitizeSlugData } from 'ish-core/utils/routing';

/**
* generate a localized content page slug
*
* @param page content page element for slug
* @returns localized, formatted content page slug
*/
function generateLocalizedContentPageSlug(page: ContentPageTreeElement) {
return sanitizeSlugData(page?.name);
}

// matcher to check if a given url is a content page route
const contentRouteFormat = /^\/(?!page\/.*$)(.*-)?pg(.*)$/;

/**
* check if given url is a content page route
*
* @param segments current url segments
* @returns match result if given url is a content page route or not
*/
export function matchContentRoute(segments: UrlSegment[]): UrlMatchResult {
// compatibility to old routes
if (segments && segments.length === 2 && segments[0].path === 'page') {
return {
consumed: [],
};
}

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

/**
* generate a localized content page url from a content page
*
* @param page content page
* @returns localized content page url
*/
export function generateContentPageUrl(page: ContentPageTreeView): string {
if (!page) {
return '/';
}

let route = '/';

// generate for each path element from the given content page hierarchy a content page slug and join them together to a complete route
route += page.pathElements?.map(p => generateLocalizedContentPageSlug(p)).join('/');

// add to content page route the content page identifier
if (route !== '/') {
route += '-';
}
route += `pg${page.contentPageId}`;

return route;
}

export function ofContentPageUrl(): MonoTypeOperatorFunction<{}> {
return source$ => source$.pipe(filter((state: CoreState) => !!selectRouteParam('contentPageId')(state)));
}
Loading

0 comments on commit 7a71372

Please sign in to comment.