Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(aio): update redirects to fix unwanted 404s #21712

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 4 additions & 2 deletions aio/content/guide/change-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,13 @@ The documentation for the version prior to v.2.2.0 has been removed.

## ES6 described in "TypeScript to JavaScript" (2016-11-14)

The updated TypeScript to JavaScript guide (removed August 2017, PR #18694)
explains how to write apps in ES6/7
The updated TypeScript to JavaScript guide explains how to write apps in ES6/7
by translating the common idioms in the TypeScript documentation examples
(and elsewhere on the web) to ES6/7 and ES5.

This was [removed in August 2017](https://github.com/angular/angular/pull/18694) but can still be
viewed in the [v2 documentation](https://v2.angular.io/docs/ts/latest/cookbook/ts-to-js.html).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️


## Sync with Angular v.2.1.1 (2016-10-21)

Docs and code samples updated and tested with Angular v.2.1.1.
Expand Down
109 changes: 76 additions & 33 deletions aio/firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,50 +12,93 @@
// make sure the routing RegExp in `ngsw-manifest.json` is updated accordingly.
//////////////////////////////////////////////////////////////////////////////////////////////

// cli-quickstart.html, glossary.html, quickstart.html, server-communication.html, style-guide.html
{"type": 301, "source": "/docs/ts/latest/cli-quickstart.html", "destination": "/guide/quickstart"},
{"type": 301, "source": "/docs/ts/latest/glossary.html", "destination": "/guide/glossary"},
{"type": 301, "source": "/docs/ts/latest/quickstart.html", "destination": "/guide/quickstart"},
{"type": 301, "source": "/docs/ts/latest/guide/server-communication.html", "destination": "/guide/http"},
{"type": 301, "source": "/docs/ts/latest/guide/style-guide.html", "destination": "/guide/styleguide"},
// A random bad indexed page that used `api/api`
{"type": 301, "source": "/api/api/:rest*", "destination": "api/:rest"},

// guide/cli-quickstart, styleguide
// Guide renames
{"type": 301, "source": "/docs/*/latest/cli-quickstart.html", "destination": "/guide/quickstart"},
{"type": 301, "source": "/docs/*/latest/glossary.html", "destination": "/guide/glossary"},
{"type": 301, "source": "/docs/*/latest/quickstart.html", "destination": "/guide/quickstart"},
{"type": 301, "source": "/docs/*/latest/guide/server-communication.html", "destination": "/guide/http"},
{"type": 301, "source": "/docs/*/latest/guide/style-guide.html", "destination": "/guide/styleguide"},
{"type": 301, "source": "/guide/cli-quickstart", "destination": "/guide/quickstart"},
{"type": 301, "source": "/styleguide", "destination": "/guide/styleguide"},
{"type": 301, "source": "/guide/service-worker-getstart", "destination": "/guide/service-worker-getting-started"},
{"type": 301, "source": "/guide/service-worker-comm", "destination": "/guide/service-worker-communications"},
{"type": 301, "source": "/guide/service-worker-configref", "destination": "/guide/service-worker-config"},

// cookbook/a1-a2-quick-reference.html, cookbook/component-communication.html, cookbook/dependency-injection.html
{"type": 301, "source": "/docs/ts/latest/cookbook/a1-a2-quick-reference.html", "destination": "/guide/ajs-quick-reference"},
{"type": 301, "source": "/docs/ts/latest/cookbook/component-communication.html", "destination": "/guide/component-interaction"},
{"type": 301, "source": "/docs/ts/latest/cookbook/dependency-injection.html", "destination": "/guide/dependency-injection-in-action"},
// some top level guide pages on old site were moved below the guide folder
{"type": 301, "source": "/styleguide", "destination": "/guide/styleguide"},
{"type": 301, "source": "/docs/styleguide", "destination": "/guide/styleguide"},

// cookbook, cookbook/, cookbook/index.html
{"type": 301, "source": "/docs/ts/latest/cookbook", "destination": "/docs"},
{"type": 301, "source": "/docs/ts/latest/cookbook/", "destination": "/docs"},
{"type": 301, "source": "/docs/ts/latest/cookbook/index.html", "destination": "/docs"},
// news is now blog
{"type": 301, "source": "/news*", "destination": "https://blog.angular.io/"},

// cookbook/*.html
{"type": 301, "source": "/docs/ts/latest/cookbook/:cookbook.html", "destination": "/guide/:cookbook"},
// cookbook guides were moved (and sometime renamed or removed)
{"type": 301, "source": "/docs/*/latest/cookbook", "destination": "/docs"},
{"type": 301, "source": "/docs/*/latest/cookbook/", "destination": "/docs"},
{"type": 301, "source": "/docs/*/latest/cookbook/index.html", "destination": "/docs"},
{"type": 301, "source": "/**/cookbook/ts-to-js*", "destination": "https://github.com/angular/angular/blob/master/aio/content/guide/change-log.md#es6--described-in-typescript-to-javascript-2016-11-14"},
{"type": 301, "source": "/docs/*/latest/cookbook/a1-a2-quick-reference.html", "destination": "/guide/ajs-quick-reference"},
{"type": 301, "source": "/docs/*/latest/cookbook/component-communication.html", "destination": "/guide/component-interaction"},
{"type": 301, "source": "/docs/*/latest/cookbook/dependency-injection.html", "destination": "/guide/dependency-injection-in-action"},
{"type": 301, "source": "/docs/*/latest/cookbook/:cookbook.html", "destination": "/guide/:cookbook"},

// docs/ts/latest/api/<package>/index/*-<type>.html (+ special case for `NgFor` which has been renamed)
{"type": 301, "source": "/docs/ts/latest/api/common/index/NgFor-directive.html", "destination": "/api/common/NgForOf"},
{"type": 301, "source": "/docs/ts/latest/api/:package/index/:api-*.html", "destination": "/api/:package/:api"},
// Forms related code was moved from the `common` to `forms` package (and NgFor was renamed to NgForOf)
{"type": 301, "source": "/**/NgFor-*", "destination": "/api/common/NgForOf"},
{"type": 301, "source": "/**/api/common/index/MaxLengthValidator-*", "destination": "/api/forms/MaxLengthValidator"},
{"type": 301, "source": "/**/api/common/ControlGroup*", "destination": "/api/forms/FormGroup"},
{"type": 301, "source": "/**/api/common/Control*", "destination": "/api/forms/FormControl"},
{"type": 301, "source": "/**/api/common/SelectControlValueAccessor-*", "destination": "/api/forms/SelectControlValueAccessor"},
{"type": 301, "source": "/**/api/common/NgModel", "destination": "/api/forms/NgModel"},

// docs/ts/latest
{"type": 301, "source": "/docs/ts/latest", "destination": "/docs"},
// AnimationStateDeclarationMetadata was removed
{"type": 301, "source": "/**/AnimationStateDeclarationMetadata*", "destination": "/api/animation"},
// `AnimationDriver` was moved to the `animations/browser` package
{"type": 301, "source": "/api/platform-browser/AnimationDriver", "destination": "/api/animations/browser/AnimationDriver"},

// guide/*, tutorial/*, **/*
{"type": 301, "source": "/docs/ts/latest/:any*", "destination": "/:any*"},
// The `testing` package was renamed to `core/testing`
{"type": 301, "source": "/api/testing/:api-*", "destination": "/api/core/testing/:api"},

// aot-compiler.md and metadata.md combined into aot-compiler.md - issue #19510
{"type": 301, "source": "/guide/metadata", "destination": "/guide/aot-compiler"},
// CORE_DIRECTIVES & PLATFORM_PIPES were removed and are now in the CommonModule
{"type": 301, "source": "/**/CORE_DIRECTIVES*", "destination": "/api/common/CommonModule"},
{"type": 301, "source": "/**/PLATFORM_PIPES*", "destination": "/api/common"},

// ngmodule.md renamed to ngmodules.md
{"type": 301, "source": "/guide/ngmodule", "destination": "/guide/ngmodules"},
// DirectiveMetadata is now covered by the Directive decorator
{"type": 301, "source": "/**/DirectiveMetadata-*", "destination": "/api/core/Directive"},

// service-worker-getstart.md, service-worker-comm.md, service-worker-configref.md
{"type": 301, "source": "/guide/service-worker-getstart", "destination": "/guide/service-worker-getting-started"},
{"type": 301, "source": "/guide/service-worker-comm", "destination": "/guide/service-worker-communications"},
{"type": 301, "source": "/guide/service-worker-configref", "destination": "/guide/service-worker-config"}
// HTTP_PROVIDERS was removed and is now provided in HttpModule
{"type": 301, "source": "/**/HTTP_PROVIDERS*", "destination": "/api/http/HttpModule"},

// URLs that use the old scheme of adding the type to the end (e.g. `SomeClass-class`)
{"type": 301, "source": "/api/:package/:api-*", "destination": "/api/:package/:api"},
{"type": 301, "source": "/api/:package/testing/index/:api-*", "destination": "/api/:package/testing/:api"},
{"type": 301, "source": "/api/:package/testing/:api-*", "destination": "/api/:package/testing/:api"},
{"type": 301, "source": "/api/upgrade/:package/index/:api-*", "destination": "/api/upgrade/:package/:api"},
{"type": 301, "source": "/api/upgrade/:package/:api-*", "destination": "/api/upgrade/:package/:api"},

// URLs that use the old scheme before we moved the docs to the angular/angular repo
{"type": 301, "source": "/docs/*/latest", "destination": "/docs"},
{"type": 301, "source": "/docs/*/latest/api/:package", "destination": "/api/:package"},
{"type": 301, "source": "/docs/*/latest/api/:package/:api-*", "destination": "/api/:package/:api"},
{"type": 301, "source": "/docs/*/latest/api/:package/index/:api-*", "destination": "/api/:package/:api"},
{"type": 301, "source": "/docs/*/latest/api/:package/testing", "destination": "/api/:package/testing"},
{"type": 301, "source": "/docs/*/latest/api/:package/testing/index/:api-*", "destination": "/api/:package/testing/:api"},
{"type": 301, "source": "/docs/*/latest/api/platform-browser/animations/index/:api-*", "destination": "/api/platform-browser/animations/:api"},
{"type": 301, "source": "/docs/*/latest/api/testing/:api-*", "destination": "/api/core/testing/:api"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, URL matching this pattern will also match /docs/*/latest/api/:package/:api-*, which appears above and as result be redirected to /api/testing/:api instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think since it will then do another round of redirect it will end up at the correct place. E.g.

/docs/ts/latest/api/testing/fakeAsync-function.html =>
  /api/testing/fakeAsync =>
     /api/core/testing/fakeAsync

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know redirects are applied recursively 👍

{"type": 301, "source": "/docs/*/latest/api/upgrade/:package/:api-*", "destination": "/api/upgrade/:package/:api"},
{"type": 301, "source": "/docs/*/latest/api/upgrade/:package/index/:api-*", "destination": "/api/upgrade/:package/:api"},
{"type": 301, "source": "/docs/*/latest/api", "destination": "/api"},
{"type": 301, "source": "/docs/*/latest/glossary", "destination": "/guide/glossary"},
{"type": 301, "source": "/docs/*/latest/guide/", "destination": "/guide"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this supposed to go. I don't think we have a /guide URL 😕

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will 404 at the moment - we should have a placeholder for that folder really.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not redirect to /docs?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it would motivate us to fix it

{"type": 301, "source": "/docs/*/latest/guide/lifecycle-hooks", "destination": "/guide/lifecycle-hooks"},
{"type": 301, "source": "/docs/*/latest/:any*", "destination": "/:any*"},
{"type": 301, "source": "/docs/latest/:any*", "destination": "/:any*"},
{"type": 301, "source": "/docs/styleguide*", "destination": "/guide/styleguide"},
{"type": 301, "source": "/guide/metadata", "destination": "/guide/aot-compiler"},
{"type": 301, "source": "/guide/ngmodule", "destination": "/guide/ngmodules"},
{"type": 301, "source": "/guide/learning-angular*", "destination": "/guide/quickstart"},
{"type": 301, "source": "/testing", "destination": "/guide/testing"},
{"type": 301, "source": "/testing/**", "destination": "/guide/testing"}
],
"rewrites": [
{
Expand Down
2 changes: 1 addition & 1 deletion aio/ngsw-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"routing": {
"index": "/index.html",
"routes": {
"^(?!/docs/ts/latest|/guide/(?:cli-quickstart|metadata|ngmodule|service-worker-(?:getstart|comm|configref))/?$|/styleguide).*/(?!e?plnkr)[^/.]*$": {
"^(?!/docs/.|(?:/guide/(?:cli-quickstart|metadata|ngmodule|service-worker-(?:getstart|comm|configref)|learning-angular)|/news/?)$|/testing|/api/(?:common/NgModel|platform-browser/AnimationDriver|testing|api)).*/(?!e?plnkr|(?:NgFor|MaxLengthValidator)-|Control(?:Group)?|AnimationStateDeclarationMetadata|CORE_DIRECTIVES|PLATFORM_PIPES|DirectiveMetadata|HTTP_PROVIDERS)[^/.]*$": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /? shuld be outside the parens in |/news/?)$:

-|/news/?)$
+|/news)/?$

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure?
This is how it is in the current 5.2: https://github.com/angular/angular/blob/5.2.x/aio/ngsw-manifest.json#L22

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because of this PR. Before that, the optional trailing / was "applied" to all guides inside the parenthesis ((?:cli-quickstart|metadata|ngmodule|service-worker-(?:getstart|comm|configref))).
We still need this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this PR has not landed on 5.2 yet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently, some of it did 😁

"match": "regex"
}
}
Expand Down
4 changes: 4 additions & 0 deletions aio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"docs-watch": "node tools/transforms/authors-package/watchr.js",
"docs-lint": "eslint --ignore-path=\"tools/transforms/.eslintignore\" tools/transforms",
"docs-test": "node tools/transforms/test.js",
"redirects-test": "jasmine-ts tools/firebase-test-utils/*.spec.ts",
"tools-test": "./scripts/deploy-to-firebase.test.sh && yarn docs-test && yarn boilerplate:test && jasmine tools/ng-packages-installer/index.spec.js",
"serve-and-sync": "concurrently --kill-others \"yarn docs-watch\" \"yarn start\"",
"boilerplate:add": "node ./tools/examples/example-boilerplate add",
Expand Down Expand Up @@ -96,6 +97,7 @@
"archiver": "^1.3.0",
"canonical-path": "^0.0.2",
"chalk": "^2.1.0",
"cjson": "^0.5.0",
"codelyzer": "~2.0.0",
"concurrently": "^3.4.0",
"cross-spawn": "^5.1.0",
Expand All @@ -116,6 +118,7 @@
"image-size": "^0.5.1",
"jasmine-core": "^2.8.0",
"jasmine-spec-reporter": "^4.1.0",
"jasmine-ts": "^0.2.1",
"jsdom": "^9.12.0",
"karma": "^1.7.0",
"karma-chrome-launcher": "^2.1.1",
Expand Down Expand Up @@ -145,6 +148,7 @@
"unist-util-visit-parents": "^1.1.1",
"vrsource-tslint-rules": "^4.0.1",
"watchr": "^3.0.1",
"xregexp": "^4.0.0",
"yargs": "^7.0.2"
}
}
3 changes: 3 additions & 0 deletions aio/scripts/deploy-to-firebase.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ fi
# Include any mode-specific files
cp -rf src/extra-files/$deployEnv/. dist/

# Check that the redirects are setup correctly
yarn redirects-test
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to test this in test-aio.sh instead of here.
There is not reason to prevent a preview on PRs if redirects are broken.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just thinking about that on the way home

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually deploys to firebase, so it would not prevent PRs from getting previews, it would prevent deployments to master and stable.

The real problem is that we need to run these tests in PRs that are not being deployed to Firebase.


# Check payload size
yarn payload-size

Expand Down
54 changes: 54 additions & 0 deletions aio/tools/firebase-test-utils/FirebaseGlob.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { FirebaseGlob } from './FirebaseGlob';

describe('FirebaseGlob', () => {

describe('test', () => {
it('should match * parts', () => {
testGlob('asdf/*.jpg',
['asdf/asdf.jpg', 'asdf/asdf_asdf.jpg', 'asdf/.jpg'],
['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx']);
});

it('should match ** parts', () => {
testGlob('asdf/**.jpg',
['asdf/asdf.jpg', 'asdf/asdf_asdf.jpg', 'asdf/asdf/asdf.jpg', 'asdf/asdf/asdf/asdf/asdf.jpg'],
['/asdf/asdf.jpg', 'asdff/asdf.jpg', 'xxxasdf/asdf.jpgxxx']);

testGlob('**/*.js',
['asdf/asdf.js', 'asdf/asdf/asdfasdf_asdf.js', '/asdf/asdf.js', '/asdf/aasdf-asdf.2.1.4.js'],
['/asdf/asdf.jpg', 'asdf.js']);
});

it('should match groups', () => {
testGlob('asdf/*.(jpg|jpeg)',
Copy link
Member

@gkalpak gkalpak Jan 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this, we should use @(jpg|jpeg) (for choosing one of the values) 😕

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is my bad.

['asdf/asdf.jpg', 'asdf/asdf_asdf.jpeg'],
['/asdf/asdf.jpg', 'asdff/asdf.jpg']);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe test with a different extension as well.

});

it('should match named parts', () => {
testGlob('/api/:package/:api-*',
['/api/common/NgClass-directive', '/api/core/Renderer-class'],
['/moo/common/NgClass-directive', '/api/common/', '/api/common/NgClass']);
});

it('should match wildcard named parts', () => {
testGlob('/api/:rest*',
['/api/a', '/api/a/b'],
['/apx/a', '/apx/a/b']);
});
});

describe('match', () => {
it('should extract the named parts', () => {
const glob = new FirebaseGlob('/api/:package/:api-*');
const match: any = glob.match('/api/common/NgClass-directive');
expect(match).toEqual({package: 'common', api: 'NgClass'});
});
});
});

function testGlob(pattern: string, matches: string[], nonMatches: string[]) {
const glob = new FirebaseGlob(pattern);
matches.forEach(url => expect(glob.test(url)).toBe(true, url));
nonMatches.forEach(url => expect(glob.test(url)).toBe(false, url));
}
38 changes: 38 additions & 0 deletions aio/tools/firebase-test-utils/FirebaseGlob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as XRegExp from 'xregexp';

const questionExpr = /\?([^(])/g;
const catchAllNamedMatchExpr = /\/:([A-Za-z]+)\*/g
const namedMatchExpr = /\/:([A-Za-z]+)([^/]*)/g
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The ([^/]*) part seems redundant.

const wildcardSegmentExpr = /(^\/).🐷\//;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ^ seems wrong. What is it for? Why only match at the beginning of the string?
(Also, no test for this 😇)


export class FirebaseGlob {
pattern: string;
regex: XRegExp;
constructor(glob: string) {
const pattern = glob
.replace('.', '\\.')
.replace(questionExpr, '[^/]$1')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no test for that 😉
(Maybe only add the features that we need (and test).)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to mock as close to Firebase as possible, otherwise our own redirect tests might not represent what happens in the real deployment. Perhaps we inadvertently add in a @(...) to the redirect source but our mock doesn't support such expressions. The test might pass on the mock but fail in reality.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 for that, as long as we test all the features we support 😛

.replace(catchAllNamedMatchExpr, '/(?<$1>.+)')
.replace(namedMatchExpr, '/(?<$1>[^/]+)$2')
.replace('**', '.🐷')
.replace('*', '[^/]*')
.replace(wildcardSegmentExpr, '(?:$1|/.*/)')
.replace('.🐷', '.*');
this.pattern = `^${pattern}$`;
this.regex = XRegExp(this.pattern);
}

test(url: string) {
return XRegExp.test(url, this.regex);
}

match(url: string) {
const match = XRegExp.exec(url, this.regex);
if (match) {
const result = {};
const names = (this.regex as any).xregexp.captureNames || [];
names.forEach(name => result[name] = match[name]);
return result;
}
}
}
22 changes: 22 additions & 0 deletions aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FirebaseRedirect } from './FirebaseRedirect';

describe('FirebaseRedirect', () => {
describe('replace', () => {
it('should inject the named captures into the destination', () => {
const redirect = new FirebaseRedirect('/api/:package/:api-*', '<:package><:api>');
const newUrl = redirect.replace('/api/common/NgClass-directive');
expect(newUrl).toEqual('<common><NgClass>');
});

it('should handle empty ** sections', () => {
const redirect2 = new FirebaseRedirect('/**/a/b', '/xxx');
expect(redirect2.replace('/a/b')).toEqual('/xxx');
});

it('should return `undefined` if the redirect failed to match', () => {
const redirect = new FirebaseRedirect('/api/:package/:api-*', '<:package><:api>');
const newUrl = redirect.replace('/common/NgClass-directive');
expect(newUrl).toBe(undefined);
});
});
});
15 changes: 15 additions & 0 deletions aio/tools/firebase-test-utils/FirebaseRedirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as XRegExp from 'xregexp';
import { FirebaseGlob } from './FirebaseGlob';

export class FirebaseRedirect {
glob = new FirebaseGlob(this.source);
constructor(public source: string, public destination: string) {}

replace(url: string) {
const match = this.glob.match(url);
if (match) {
const replacers = Object.keys(match).map(name => [ XRegExp(`:${name}`, 'g'), match[name] ]);
return XRegExp.replaceEach(this.destination, replacers);
}
}
}
42 changes: 42 additions & 0 deletions aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FirebaseRedirector } from './FirebaseRedirector';

describe('FirebaseRedirector', () => {
it('should replace with the first matching redirect', () => {
const redirector = new FirebaseRedirector([
{ source: '/a/b/c', destination: '/X/Y/Z' },
{ source: '/a/:foo/c', destination: '/X/:foo/Z' },
{ source: '/**/:foo/c', destination: '/A/:foo/zzz' },
]);
expect(redirector.redirect('/a/b/c')).toEqual('/X/Y/Z');
expect(redirector.redirect('/a/moo/c')).toEqual('/X/moo/Z');
expect(redirector.redirect('/x/y/a/b/c')).toEqual('/A/b/zzz');
expect(redirector.redirect('/x/y/c')).toEqual('/A/y/zzz');
});

it('should return the original url if no redirect matches', () => {
const redirector = new FirebaseRedirector([
{ source: 'x', destination: 'X' },
{ source: 'y', destination: 'Y' },
{ source: 'z', destination: 'Z' },
]);
expect(redirector.redirect('a')).toEqual('a');
});

it('should recursively redirect', () => {
const redirector = new FirebaseRedirector([
{ source: 'a', destination: 'b' },
{ source: 'b', destination: 'c' },
{ source: 'c', destination: 'd' },
]);
expect(redirector.redirect('a')).toEqual('d');
});

it('should throw if stuck in an infinite loop', () => {
const redirector = new FirebaseRedirector([
{ source: 'a', destination: 'b' },
{ source: 'b', destination: 'c' },
{ source: 'c', destination: 'a' },
]);
expect(() => redirector.redirect('a')).toThrowError('infinite redirect loop');
});
});