Skip to content

Commit

Permalink
feat: add URLs resolution given a base URL (#798)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidlj95 authored Oct 2, 2024
1 parent 1fdf846 commit 712436f
Show file tree
Hide file tree
Showing 46 changed files with 1,029 additions and 36 deletions.
28 changes: 25 additions & 3 deletions projects/ngx-meta/api-extractor/ngx-meta.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import { MetaDefinition } from '@angular/platform-browser';
import { ModuleWithProviders } from '@angular/core';
import { Provider } from '@angular/core';

// @public
export const ANGULAR_ROUTER_URL: unique symbol;

// @public
export type AngularRouterUrl = typeof ANGULAR_ROUTER_URL;

// @public
export type BaseUrl = string;

// Warning: (ae-forgotten-export) The symbol "CoreFeatureKind" needs to be exported by the entry point all-entry-points.d.ts
//
// @internal (undocumented)
Expand All @@ -24,6 +33,8 @@ interface CoreFeature<FeatureKind extends CoreFeatureKind> {

// @internal
const enum CoreFeatureKind {
// (undocumented)
BaseUrl = 1,
// (undocumented)
Defaults = 0
}
Expand Down Expand Up @@ -69,7 +80,7 @@ export const _GLOBAL_TITLE = "title";
// @public
export interface GlobalMetadata {
readonly applicationName?: string | null;
readonly canonicalUrl?: URL | string | null;
readonly canonicalUrl?: URL | AngularRouterUrl | string | null;
readonly description?: string | null;
readonly image?: GlobalMetadataImage | null;
readonly locale?: string | null;
Expand Down Expand Up @@ -127,7 +138,9 @@ export interface MakeMetadataManagerProviderFromSetterFactoryOptions {
}

// @internal
export const _maybeNonHttpUrlDevMessage: (url: string | URL | undefined | null, opts: _FormatDevMessageOptions) => void;
export const _maybeNonHttpUrlDevMessage: (url: string | URL | undefined | null, opts: _FormatDevMessageOptions & {
shouldInsteadOfMust?: boolean;
}) => void;

// @internal
export const _maybeTooLongDevMessage: (value: string | undefined | null, maxLength: number, opts: _FormatDevMessageOptions) => void;
Expand Down Expand Up @@ -310,7 +323,7 @@ export interface OpenGraph {
readonly siteName?: string | null;
readonly title?: string | null;
readonly type?: OpenGraphType | null;
readonly url?: URL | string | null;
readonly url?: URL | AngularRouterUrl | string | null;
}

// @public
Expand Down Expand Up @@ -507,6 +520,15 @@ export interface TwitterCardSiteUsername {
// @public
export type TwitterCardType = typeof TWITTER_CARD_TYPE_SUMMARY | typeof TWITTER_CARD_TYPE_SUMMARY_LARGE_IMAGE | typeof TWITTER_CARD_TYPE_APP | typeof TWITTER_CARD_TYPE_PLAYER;

// @internal
export const _URL_RESOLVER: InjectionToken<_UrlResolver>;

// @internal (undocumented)
export type _UrlResolver = (url: URL | string | undefined | null | AngularRouterUrl) => string | undefined | null;

// @public
export const withNgxMetaBaseUrl: (baseUrl: BaseUrl) => CoreFeature<CoreFeatureKind.BaseUrl>;

// @public
export const withNgxMetaDefaults: (defaults: MetadataValues) => CoreFeature<CoreFeatureKind.Defaults>;

Expand Down
169 changes: 169 additions & 0 deletions projects/ngx-meta/docs/content/guides/url-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# URL resolution

Some metadata values need a URL as value. Like [canonical URL](ngx-meta.globalmetadata.canonicalurl.md) metadata. And in some cases, an absolute URL is required or preferred. For instance [standard module]'s [canonical URL](ngx-meta.standard.canonicalurl.md) or [Open Graph module]'s [URL](ngx-meta.opengraph.url.md)

??? tip "Both URLs mentioned can be set at the same time"

By using mentioned [canonical URL](ngx-meta.globalmetadata.md) [global metadata](ngx-meta.globalmetadata.md). See [metadata values JSON guide](metadata-values-json.md) for more info

Providing an absolute URL over and over could be repetitive. For instance:

```typescript
const fooPageMetadata: GlobalMetadata = {
canonicalUrl: 'https://example.com/app/foo',
}
const barPageMetadata: GlobalMetadata = {
canonicalUrl: 'https://example.com/app/bar',
}
```

The same URL prefix repeats around `https://example.com/app`.

But don't worry, got you covered 😉 Set up URL resolution and the problem will be over

## Set up

To avoid repeating the same URL prefix over and over, the library provides a way to configure a **base URL**. This way, when specifying a relative URL where an absolute URL is required or preferred, the **base URL** will be prepended. So that eventually an absolute URL appears as
metadata value.

=== "For standalone, module-free apps"

--8<-- "includes/standalone-apps-explanation.md"

Add [`withNgxMetaBaseUrl`](ngx-meta.withngxmetabaseurl.md) as a [`provideNgxMetaCore`](ngx-meta.providengxmetacore.md) feature to your standalone app's `app.config.ts` file providers.

```typescript title="app.config.ts"
import {provideNgxMetaCore, withNgxMetaBaseUrl} from '@davidlj95/ngx-meta/core';

export const appConfig: ApplicationConfig = {
// ...
providers: [
// ...
provideNgxMetaCore(
withNgxMetaBaseUrl('https://example.com/app')
),
// ...
],
}
```

=== "For non-standalone, module-based apps"

--8<-- "includes/module-apps-explanation.md"

Add [`withNgxMetaBaseUrl`](ngx-meta.withngxmetabaseurl.md) as an [`NgxMetaCoreModule.forRoot`](ngx-meta.ngxmetacoremodule.forroot.md) feature in `app.module.ts` file.

```typescript title="app.module.ts"
import {NgxMetaCoreModule, withNgxMetaBaseUrl} from '@davidlj95/ngx-meta/core';

@NgModule({
// ...
imports: [
// ...
NgxMetaCoreModule.forRoot(
withNgxMetaBaseUrl('https://example.com/app'),
),
// ...
],
// ...
})
export class AppModule {}
```

## Usage

### With a relative URL

Once set up, you can specify a relative URL as URL and the absolute URL will be resolved for you behind the scenes. The initial example setting some canonical URLs could now be reduced to:

```typescript
const fooPageMetadata: GlobalMetadata = {
canonicalUrl: 'foo', // value will be 'https://example.com/app/foo'
}
const barPageMetadata: GlobalMetadata = {
canonicalUrl: 'bar', // value will be 'https://example.com/app/bar'
}
```

Pretty neat, isn't it?

### With Angular router's URL

What if the relative URL you want to use is the same one used for the Angular's router route? In that case, you can provide the magic value [`ANGULAR_ROUTER_URL`](ngx-meta.angularrouterurl.md). This will instruct the library to use the current [Angular's router URL](https://angular.dev/api/router/Route/#url) as relative URL. Which in turn will be resolved into an absolute URL.

```typescript
const routes = [
{
path: 'foo',
component: FooComponent,
},
// ...
]
const fooPageMetadata: GlobalMetadata = {
canonicalurl: ANGULAR_ROUTER_URL, // value will be 'https://example.com/app/foo'
}
```

!!! danger "URL resolution must be enabled to use Angular router URL"

Otherwise an invalid URL will be used as metadata value. Specifically, the [`ANGULAR_ROUTER_URL`](ngx-meta.angularrouterurl.md) symbol converted to string.

## Recipes

### Using defaults

You can also use the previous [`ANGULAR_ROUTER_URL`](ngx-meta.angularrouterurl.md) value as a [default value](defaults.md) for some metadata. This way the Angular router's URL will be used as default if no other value is specified.

=== "For standalone, module-free apps"

--8<-- "includes/standalone-apps-explanation.md"

```typescript title="app.config.ts"
import {provideNgxMetaCore, withNgxMetaBaseUrl} from '@davidlj95/ngx-meta/core';

export const appConfig: ApplicationConfig = {
// ...
providers: [
// ...
provideNgxMetaCore(
withNgxMetaDefaults(
{
canonicalUrl: ANGULAR_ROUTER_URL,
} satisfies GlobalMetadata
)
),
// ...
],
}
```

=== "For non-standalone, module-based apps"

--8<-- "includes/module-apps-explanation.md"

```typescript title="app.module.ts"
import {NgxMetaCoreModule, withNgxMetaDefaults, ANGULAR_ROUTER_URL} from '@davidlj95/ngx-meta/core';

@NgModule({
// ...
imports: [
// ...
NgxMetaCoreModule.forRoot(
withNgxMetaDefaults({
canonicalUrl: ANGULAR_ROUTER_URL,
} satisfies GlobalMetadata),
),
// ...
],
// ...
})
export class AppModule {}
```

## Implementation notes

The provided base URL string will be prepended to the relative URL value. The only adjustments that are made are:

- **Double slashes are avoided**. Base URL `https://example.com/app/` (trailing slash) + relative URL `/foo` (leading slash) will result in `https://example.com/app/foo`
- **Slash is added when needed**. Base URL `https://example.com/app` (no trailing slash) + relative URL `foo` (no leading slash) will result in `https://example.com/app/foo`
- **No trailing slash for home is fine**. Base URL `https://example.com/app` (no trailing slash) and an empty string relative URL will result in `https://example.com/app` (base URL as is). Beware that if using [`ANGULAR_ROUTER_URL`](ngx-meta.angularrouterurl.md) the router root URL is `/`. So if using the previous base URL, the result for the home / root page would be `https://example.com/app/` (with trailing slash).
2 changes: 1 addition & 1 deletion projects/ngx-meta/docs/content/why/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Declarative APIs are preferred to offer to library's users. The user just tells

## Extendable

Despite built-in tools to set common used metadata, users may have their own needs. So library must allow to manage them in the same fashion as built-in metadata managers do.
Despite built-in tools to set common used metadata, users may have their own needs. So library must allow to manage them in the same fashion as built-in metadata managers do. This includes any kind of metadata, not just the regular `#!html <meta>` elements in the `#!html <head>` of the page.

## Tree-shakeable

Expand Down
4 changes: 4 additions & 0 deletions projects/ngx-meta/docs/content/why/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Whole library is designed to be [_tree shakeable_][tree shaking]. So metadata mo

To reduce main bundle size, you can also lazy load some metadata. This way, you don't load metadata management code for some metadata elements until you don't need them. For more information, check the [late loading modules guide](late-loading-modules.md)

### ✨ URL resolution

For metadata values where an absolute URL is required, a base URL can be provided so that you only need to specify relative URLs around and don't repeat the app URL over and over around. Or use the Angular's route path if that one is appropriate. Check out [URL resolution guide](url-resolution.md) for more information.

### 0️⃣ Zero dependencies[^2]

So less pain with dependency management
Expand Down
1 change: 1 addition & 0 deletions projects/ngx-meta/docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ nav:
- guides/set-metadata-using-routing.md
- guides/metadata-values-json.md
- guides/defaults.md
- guides/url-resolution.md
- guides/late-loading-modules.md
- guides/manage-your-custom-metadata.md
- guides/custom-metadata-providers-selection.md
Expand Down
30 changes: 30 additions & 0 deletions projects/ngx-meta/e2e/cypress/e2e/url-resolution-meta.spec.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ROUTES } from '../fixtures/routes'
import {
shouldNotEmitUnwantedConsoleLogs,
spyOnConsole,
} from '../support/console'
import { standardCanonicalUrlShouldEqual } from '../support/metadata/standard'
import { testWithSsrAndCsr } from '../support/test-with-ssr-and-csr'
import { openGraphUrlShouldEqual } from '../support/metadata/open-graph'
import { BASE_URL } from '../fixtures/base-url'

describe('URL resolution meta', () => {
const expectedCanonicalUrl = `${BASE_URL}/${ROUTES.urlResolutionMeta.path}`

testWithSsrAndCsr(
{
url: ROUTES.urlResolutionMeta.path,
onBeforeLoad: spyOnConsole,
},
{
ssrAndCsr: () => {
shouldNotEmitUnwantedConsoleLogs()

it('should resolve Angular router URL to absolute URL using base URL', () => {
standardCanonicalUrlShouldEqual(expectedCanonicalUrl)
openGraphUrlShouldEqual(expectedCanonicalUrl)
})
},
},
)
})
1 change: 1 addition & 0 deletions projects/ngx-meta/e2e/cypress/fixtures/base-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const BASE_URL = 'https://e2e.example.com'
5 changes: 5 additions & 0 deletions projects/ngx-meta/e2e/cypress/fixtures/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ export const ROUTES = {
displayName: 'Meta late loaded + custom',
linkId: undefined,
},
urlResolutionMeta: {
path: 'url-resolution-meta',
displayName: 'URL resolution meta',
linkId: undefined,
},
}
8 changes: 5 additions & 3 deletions projects/ngx-meta/e2e/cypress/support/metadata/open-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ export const shouldContainAllOpenGraphMetadata = (
cy.getMetaWithProperty('og:image:height')
.shouldHaveContent()
.and('eq', metadata.openGraph.image.height.toString())
cy.getMetaWithProperty('og:url')
.shouldHaveContent()
.and('eq', metadata.canonicalUrl)
openGraphUrlShouldEqual(metadata.canonicalUrl)
cy.getMetaWithProperty('og:description')
.shouldHaveContent()
.and('eq', metadata.description)
Expand All @@ -55,6 +53,10 @@ export function openGraphTitleShouldEqual(title: string) {
cy.getMetaWithProperty('og:title').shouldHaveContent().and('eq', title)
}

export function openGraphUrlShouldEqual(url: string) {
cy.getMetaWithProperty('og:url').shouldHaveContent().and('eq', url)
}

export const shouldNotContainAnyOpenGraphMetadata = () =>
it('should not contain any Open Graph metadata', () => {
cy.getMetaWithProperty('og:title').should('not.exist')
Expand Down
10 changes: 7 additions & 3 deletions projects/ngx-meta/e2e/cypress/support/metadata/standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ export const shouldContainAllStandardMetadata = () =>
cy.getMeta('application-name')
.shouldHaveContent()
.and('eq', metadata.applicationName)
cy.get('link[rel="canonical"]')
.should('have.attr', 'href')
.and('eq', metadata.canonicalUrl)
standardCanonicalUrlShouldEqual(metadata.canonicalUrl)
cy.get('html').should('have.attr', 'lang').and('eq', metadata.locale)
cy.getMeta('theme-color')
.shouldHaveContent()
Expand All @@ -38,6 +36,12 @@ export function standardDescriptionShouldEqual(description: string) {
cy.getMeta('description').shouldHaveContent().and('eq', description)
}

export function standardCanonicalUrlShouldEqual(canonicalUrl: string) {
cy.get('link[rel="canonical"]')
.should('have.attr', 'href')
.and('eq', canonicalUrl)
}

export const shouldNotContainAnyStandardMetadata = () =>
it('should not contain any standard metadata', () => {
cy.getMeta('description').should('not.exist')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NgxMetaRouteData } from '@davidlj95/ngx-meta/routing'
import ALL_METADATA_JSON from '@/e2e/cypress/fixtures/all-metadata.json'
import { MetaLateLoadedModule } from './meta-late-loaded/meta-late-loaded.module'
import { OneMetaSetByServiceComponent } from './one-meta-set-by-service/one-meta-set-by-service.component'
import { UrlResolutionMetaComponent } from './url-resolution-meta/url-resolution-meta.component'

const ngxMetaRouteData: NgxMetaRouteData = { meta: ALL_METADATA_JSON }

Expand All @@ -34,6 +35,10 @@ const routes: Routes = [
path: ROUTES.metaLateLoaded.path,
loadChildren: () => MetaLateLoadedModule,
},
{
path: ROUTES.urlResolutionMeta.path,
component: UrlResolutionMetaComponent,
},
]

@NgModule({
Expand Down
Loading

0 comments on commit 712436f

Please sign in to comment.