Browse Source

Task/improve meta descriptions of public pages (#8369)

* Improve meta descriptions of public pages

* Update changelog
pull/8403/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
0826fb5f80
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 49
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  3. 144
      apps/api/src/middlewares/html-template.middleware.spec.ts
  4. 186
      apps/api/src/middlewares/html-template.middleware.ts
  5. 109
      apps/api/src/services/i18n/i18n.service.spec.ts
  6. 36
      apps/api/src/services/i18n/i18n.service.ts
  7. 99
      apps/client/src/app/pages/i18n/i18n-page.html

1
CHANGELOG.md

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the content of the pricing page - Improved the content of the pricing page
- Changed the viewport to resize the content when the on-screen keyboard opens on _Android_ (`interactive-widget=resizes-content`) - Changed the viewport to resize the content when the on-screen keyboard opens on _Android_ (`interactive-widget=resizes-content`)
- Improved the meta descriptions of the public pages and the blog posts
- Disabled the biometric authentication on all devices when generating a new _Security Token_ - Disabled the biometric authentication on all devices when generating a new _Security Token_
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)

49
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -9,9 +9,6 @@ import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class SitemapService { export class SitemapService {
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService private readonly i18nService: I18nService
@ -148,26 +145,13 @@ export class SitemapService {
const rootUrl = this.configurationService.get('ROOT_URL'); const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
const resourcesPath = this.i18nService.getTranslation({ const [resourcesPath, personalFinanceToolsPath, productPath] =
languageCode, this.i18nService.getLocalizedRouterLink({
id: publicRoutes.resources.path.match( languageCode,
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX routerLink:
).groups.id publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
}); .product.routerLink
});
const personalFinanceToolsPath = this.i18nService.getTranslation({
languageCode,
id: publicRoutes.resources.subRoutes.personalFinanceTools.path.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
).groups.id
});
const productPath = this.i18nService.getTranslation({
languageCode,
id: publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
).groups.id
});
return personalFinanceTools.map(({ alias, key }) => { return personalFinanceTools.map(({ alias, key }) => {
const routerLink = [ const routerLink = [
@ -217,21 +201,10 @@ export class SitemapService {
rootUrl: string; rootUrl: string;
route?: PublicRoute; route?: PublicRoute;
}): string { }): string {
const segments = const segments = this.i18nService.getLocalizedRouterLink({
route?.routerLink.map((link) => { languageCode,
const match = link.match( routerLink: route?.routerLink ?? []
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX });
);
const segment = match
? (this.i18nService.getTranslation({
languageCode,
id: match.groups.id
}) ?? match.groups.message)
: link;
return segment.replace(/^\/+|\/+$/, '');
}) ?? [];
const location = [rootUrl, languageCode, ...segments].join('/'); const location = [rootUrl, languageCode, ...segments].join('/');

144
apps/api/src/middlewares/html-template.middleware.spec.ts

@ -0,0 +1,144 @@
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { Logger } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { readFileSync, readdirSync } from 'node:fs';
import { HtmlTemplateMiddleware } from './html-template.middleware';
jest.mock('@ghostfolio/api/environments/environment', () => {
return { environment: { production: true, rootUrl: 'https://ghostfol.io' } };
});
jest.mock('node:fs', () => {
return {
...jest.requireActual('node:fs'),
readFileSync: jest.fn(),
readdirSync: jest.fn()
};
});
const genericDescription = 'Generic description';
const indexHtml = '<meta content="${description}" name="description" />';
const productPath =
'/en/resources/personal-finance-tools/open-source-alternative-to-8figures';
function createLocale({
pricingDescription = 'Pricing description',
productDescription
}: {
pricingDescription?: string;
productDescription: string;
}) {
return `
<xliff>
<trans-unit id="metaDescription">
<source>${genericDescription}</source>
</trans-unit>
<trans-unit id="metaDescription.pricing">
<source>${pricingDescription}</source>
</trans-unit>
<trans-unit id="metaDescription.resources.personalFinanceTools.product">
<source>${productDescription}</source>
</trans-unit>
<trans-unit id="routes.pricing">
<source>pricing</source>
</trans-unit>
<trans-unit id="routes.resources">
<source>resources</source>
</trans-unit>
<trans-unit id="routes.resources.personalFinanceTools">
<source>personal-finance-tools</source>
</trans-unit>
<trans-unit id="routes.resources.personalFinanceTools.openSourceAlternativeTo">
<source>open-source-alternative-to</source>
</trans-unit>
</xliff>
`;
}
function getResponseBody({
locale,
originalUrl
}: {
locale: string;
originalUrl: string;
}) {
(readdirSync as jest.Mock).mockReturnValue(['messages.xlf']);
(readFileSync as jest.Mock).mockImplementation((path: string) => {
return path.endsWith('index.html') ? indexHtml : locale;
});
const i18nService = new I18nService();
i18nService.onModuleInit();
const send = jest.fn();
new HtmlTemplateMiddleware(i18nService).use(
{ originalUrl } as Request,
{ send } as unknown as Response,
jest.fn() as NextFunction
);
return send.mock.calls[0][0];
}
describe('HtmlTemplateMiddleware', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('serves the description of a public route', () => {
expect(
getResponseBody({
locale: createLocale({ productDescription: 'Compare ${name}' }),
originalUrl: '/en/pricing'
})
).toContain('content="Pricing description"');
});
it('interpolates the name into the description of a personal finance tool', () => {
expect(
getResponseBody({
locale: createLocale({ productDescription: 'Compare ${name}' }),
originalUrl: productPath
})
).toContain('content="Compare 8FIGURES"');
});
it('falls back to the generic description if the name token is not in the description of a personal finance tool', () => {
expect(
getResponseBody({
locale: createLocale({ productDescription: 'Compare ${product}' }),
originalUrl: productPath
})
).toContain(`content="${genericDescription}"`);
});
it('falls back to the generic description if the description of a personal finance tool contains another token than the name token', () => {
expect(
getResponseBody({
locale: createLocale({
productDescription: 'Compare ${name} (${product})'
}),
originalUrl: productPath
})
).toContain(`content="${genericDescription}"`);
});
it('escapes the description for the use in an attribute', () => {
expect(
getResponseBody({
locale: createLocale({
pricingDescription: 'Compare &quot;plans&quot; &amp; prices',
productDescription: 'Compare ${name}'
}),
originalUrl: '/en/pricing'
})
).toContain('content="Compare &quot;plans&quot; &amp; prices"');
});
});

186
apps/api/src/middlewares/html-template.middleware.ts

@ -6,6 +6,9 @@ import {
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -16,75 +19,135 @@ import { join } from 'node:path';
const title = 'Ghostfolio'; const title = 'Ghostfolio';
const locales = { const locales = {
'/de/blog/2021/07/hallo-ghostfolio': {
description:
'Hallo Ghostfolio: Die Vorstellung des Open Source Projekts für die persönlichen Finanzen. Welches Problem es löst und welche Vision dahinter steht.'
},
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
description:
'Ghostfolio auf Sackgeld.com: Der Bericht erläutert die Funktionsweise, die unterstützten Assets und die Preisstruktur der Open Source Software.',
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}` title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
}, },
'/en/blog/2021/07/hello-ghostfolio': {
description:
'Meet Ghostfolio, an open source personal finance software. Learn which problem it solves and the vision of an overview of all your assets in real time.'
},
'/en/blog/2022/01/ghostfolio-first-months-in-open-source': {
description:
'A look back at the first months of Ghostfolio in open source: the road to 100 stars on GitHub and why the benefits of public development outweigh the drawbacks.'
},
'/en/blog/2022/07/ghostfolio-meets-internet-identity': {
description:
'Ghostfolio now supports passwordless sign-in with Internet Identity, a blockchain authentication system. Track your portfolio without being tracked.'
},
'/en/blog/2022/07/how-do-i-get-my-finances-in-order': {
description:
'Before you start long-term investing, get your finances in order. Follow Peter as he plans his monthly budget, saves money and sets financial goals.'
},
'/en/blog/2022/08/500-stars-on-github': { '/en/blog/2022/08/500-stars-on-github': {
description:
'Ghostfolio celebrates 500 stars on GitHub. A recap of the growing community, the new message queue and the Internet Identity integration for Web3.',
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${title}` title: `500 Stars - ${title}`
}, },
'/en/blog/2022/10/hacktoberfest-2022': { '/en/blog/2022/10/hacktoberfest-2022': {
description:
'Ghostfolio joins Hacktoberfest for the first time. Make your first pull request on GitHub and contribute to the open source personal finance software.',
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${title}` title: `Hacktoberfest 2022 - ${title}`
}, },
'/en/blog/2022/11/black-friday-2022': {
description:
'Black Friday 2022 offer: 75% off the annual plan of Ghostfolio Premium. This offer has ended. See the pricing page for the current plans.'
},
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': { '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
description:
'Tracking your assets helps you reach your financial goals. Learn why to monitor your personal finances, understand your net worth and act when required.',
featureGraphicPath: 'assets/images/blog/20221226.jpg', featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${title}` title: `The importance of tracking your personal finances - ${title}`
}, },
'/en/blog/2023/02/ghostfolio-meets-umbrel': { '/en/blog/2023/02/ghostfolio-meets-umbrel': {
description:
'Ghostfolio is now available in the Umbrel App Store. Self-host the open source personal finance software on a home server and stay in control of your data.',
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${title}` title: `Ghostfolio meets Umbrel - ${title}`
}, },
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': { '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
description:
'Ghostfolio reaches 1’000 stars on GitHub. A look at the achievements of the open source personal finance software, its community and future developments.',
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}` title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`
}, },
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
description:
'Discover why Ghostfolio is the ideal open source tool to manage stocks, ETFs and cryptocurrencies across multiple platforms, with insights and privacy.',
featureGraphicPath: 'assets/images/blog/20230520.jpg', featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${title}` title: `Unlock your Financial Potential with Ghostfolio - ${title}`
}, },
'/en/blog/2023/07/exploring-the-path-to-fire': { '/en/blog/2023/07/exploring-the-path-to-fire': {
description:
'What is FIRE? Explore the concept of Financial Independence and Retiring Early, the famous 4% rule, and the pros and cons of this popular movement.',
featureGraphicPath: 'assets/images/blog/20230701.jpg', featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${title}` title: `Exploring the Path to FIRE - ${title}`
}, },
'/en/blog/2023/08/ghostfolio-joins-oss-friends': { '/en/blog/2023/08/ghostfolio-joins-oss-friends': {
description:
'Ghostfolio is now part of OSS Friends, a new initiative that helps open source projects grow. Read the story behind the movement and how to join it.',
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${title}` title: `Ghostfolio joins OSS Friends - ${title}`
}, },
'/en/blog/2023/09/ghostfolio-2': { '/en/blog/2023/09/ghostfolio-2': {
description:
'Ghostfolio 2.0 is here: extended data import, comprehensive analytics, increased stability and the latest technology in open source wealth management.',
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${title}` title: `Announcing Ghostfolio 2.0 - ${title}`
}, },
'/en/blog/2023/09/hacktoberfest-2023': { '/en/blog/2023/09/hacktoberfest-2023': {
description:
'Ghostfolio joins Hacktoberfest for the second time. Make your first pull request on GitHub and contribute to the open source personal finance software.',
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${title}` title: `Hacktoberfest 2023 - ${title}`
}, },
'/en/blog/2023/11/black-week-2023': { '/en/blog/2023/11/black-week-2023': {
description:
'Black Week 2023 offer: 33% off the annual plan of Ghostfolio Premium. This offer has ended. See the pricing page for the current plans.',
featureGraphicPath: 'assets/images/blog/black-week-2023.jpg', featureGraphicPath: 'assets/images/blog/black-week-2023.jpg',
title: `Black Week 2023 - ${title}` title: `Black Week 2023 - ${title}`
}, },
'/en/blog/2023/11/hacktoberfest-2023-debriefing': { '/en/blog/2023/11/hacktoberfest-2023-debriefing': {
description:
'Looking back at Hacktoberfest 2023 at Ghostfolio: 20 developers from around the world and as many as 100 new features and improvements in the project.',
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}` title: `Hacktoberfest 2023 Debriefing - ${title}`
}, },
'/en/blog/2024/09/hacktoberfest-2024': { '/en/blog/2024/09/hacktoberfest-2024': {
description:
'Ghostfolio joins Hacktoberfest for the third time. Make your first pull request on GitHub and contribute to the open source personal finance dashboard.',
featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png',
title: `Hacktoberfest 2024 - ${title}` title: `Hacktoberfest 2024 - ${title}`
}, },
'/en/blog/2024/11/black-weeks-2024': { '/en/blog/2024/11/black-weeks-2024': {
description:
'Black Weeks 2024 offer: 25% off the annual plan of Ghostfolio Premium plus 3 extra months. This offer has ended. See the pricing page for the current plans.',
featureGraphicPath: 'assets/images/blog/black-weeks-2024.jpg', featureGraphicPath: 'assets/images/blog/black-weeks-2024.jpg',
title: `Black Weeks 2024 - ${title}` title: `Black Weeks 2024 - ${title}`
}, },
'/en/blog/2025/09/hacktoberfest-2025': { '/en/blog/2025/09/hacktoberfest-2025': {
description:
'Ghostfolio joins Hacktoberfest for the fourth time. Make your first pull request on GitHub and contribute to the modern personal finance dashboard.',
featureGraphicPath: 'assets/images/blog/hacktoberfest-2025.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2025.png',
title: `Hacktoberfest 2025 - ${title}` title: `Hacktoberfest 2025 - ${title}`
}, },
'/en/blog/2025/11/black-weeks-2025': { '/en/blog/2025/11/black-weeks-2025': {
description:
'Black Weeks 2025 offer: 25% off the annual plan of Ghostfolio Premium plus 3 extra months. This offer has ended. See the pricing page for the current plans.',
featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg', featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg',
title: `Black Weeks 2025 - ${title}` title: `Black Weeks 2025 - ${title}`
}, },
'/en/blog/2026/04/ghostfolio-3': { '/en/blog/2026/04/ghostfolio-3': {
description:
'Ghostfolio 3.0 is here: a refreshed user interface, expanded analytics, improved stability, more languages and an updated technology stack.',
featureGraphicPath: 'assets/images/blog/ghostfolio-3.jpg', featureGraphicPath: 'assets/images/blog/ghostfolio-3.jpg',
title: `Announcing Ghostfolio 3.0 - ${title}` title: `Announcing Ghostfolio 3.0 - ${title}`
}, },
@ -101,6 +164,9 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
private readonly logger = new Logger(HtmlTemplateMiddleware.name); private readonly logger = new Logger(HtmlTemplateMiddleware.name);
private indexHtmlMap: { [languageCode: string]: string } = {}; private indexHtmlMap: { [languageCode: string]: string } = {};
private routeDescriptionsMap: {
[languageCode: string]: { [path: string]: string };
} = {};
public constructor(private readonly i18nService: I18nService) { public constructor(private readonly i18nService: I18nService) {
if (!environment.production) { if (!environment.production) {
@ -135,7 +201,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
} }
public use(request: Request, response: Response, next: NextFunction) { public use(request: Request, response: Response, next: NextFunction) {
const path = request.originalUrl.replace(/\/$/, ''); const path = request.originalUrl.split('?')[0].replace(/\/$/, '');
let languageCode = path.substr(1, 2); let languageCode = path.substr(1, 2);
if ( if (
@ -162,12 +228,14 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
languageCode, languageCode,
path, path,
rootUrl, rootUrl,
description: description: this.escapeAttribute(
locales[path]?.description ?? locales[path]?.description ??
this.i18nService.getTranslation({ this.getRouteDescription({ languageCode, path }) ??
languageCode, this.i18nService.getTranslation({
id: 'metaDescription' languageCode,
}), id: 'metaDescription'
})
),
featureGraphicPath: featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png', locales[path]?.featureGraphicPath ?? 'assets/cover.png',
keywords: this.i18nService.getTranslation({ keywords: this.i18nService.getTranslation({
@ -186,6 +254,112 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
} }
} }
private escapeAttribute(value: string) {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
private getPersonalFinanceToolDescriptions(
languageCode: string
): [string, string][] {
const description = this.i18nService.getTranslation({
languageCode,
id: 'metaDescription.resources.personalFinanceTools.product',
skipUntranslated: true
});
const tokens = description.match(/[$]{[^}]+}/g) ?? [];
if (
tokens.length === 0 ||
tokens.some((token) => {
return token !== '${name}';
})
) {
return [];
}
const path = [
'',
languageCode,
...this.i18nService.getLocalizedRouterLink({
languageCode,
routerLink:
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
.product.routerLink
})
].join('/');
return personalFinanceTools.map(({ alias, key, name }) => {
return [`${path}-${alias ?? key}`, interpolate(description, { name })];
});
}
private getPublicRouteDescriptions({
id,
languageCode,
routes
}: {
id: string;
languageCode: string;
routes: Record<string, PublicRoute>;
}): [string, string][] {
return Object.entries(routes).flatMap(([key, route]) => {
if (route.excludeFromSitemap) {
return [];
}
const routeId = `${id}.${key}`;
const path = [
'',
languageCode,
...this.i18nService.getLocalizedRouterLink({
languageCode,
routerLink: route.routerLink
})
].join('/');
const description = this.i18nService.getTranslation({
languageCode,
id: routeId,
skipUntranslated: true
});
return [
[path, description] as [string, string],
...this.getPublicRouteDescriptions({
languageCode,
id: routeId,
routes: route.subRoutes ?? {}
})
];
});
}
private getRouteDescription({
languageCode,
path
}: {
languageCode: string;
path: string;
}): string | undefined {
// Build once per language since the translation lookups are expensive
this.routeDescriptionsMap[languageCode] ??= Object.fromEntries(
[
...this.getPersonalFinanceToolDescriptions(languageCode),
...this.getPublicRouteDescriptions({
languageCode,
id: 'metaDescription',
routes: publicRoutes
})
].filter(([, description]) => {
return description;
})
);
return this.routeDescriptionsMap[languageCode][path];
}
private isFileRequest(filename: string) { private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') { if (filename === '/assets/LICENSE') {
return true; return true;

109
apps/api/src/services/i18n/i18n.service.spec.ts

@ -0,0 +1,109 @@
import { Logger } from '@nestjs/common';
import { readFileSync, readdirSync } from 'node:fs';
import { I18nService } from './i18n.service';
jest.mock('node:fs', () => {
return {
...jest.requireActual('node:fs'),
readFileSync: jest.fn(),
readdirSync: jest.fn()
};
});
const germanLocale = `
<xliff>
<trans-unit id="metaDescription.new">
<source>New</source>
<target state="new">New</target>
</trans-unit>
<trans-unit id="metaDescription.translated">
<source>Translated</source>
<target state="translated">Übersetzt</target>
</trans-unit>
<trans-unit id="routes.faq">
<source>faq</source>
<target state="translated">haeufig-gestellte-fragen</target>
</trans-unit>
<trans-unit id="routes.faq.selfHosting">
<source>self-hosting</source>
<target state="translated">selbst-hosting</target>
</trans-unit>
</xliff>
`;
describe('I18nService', () => {
let i18nService: I18nService;
let warnSpy: jest.SpyInstance;
beforeEach(() => {
(readdirSync as jest.Mock).mockReturnValue(['messages.de.xlf']);
(readFileSync as jest.Mock).mockReturnValue(germanLocale);
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
i18nService = new I18nService();
i18nService.onModuleInit();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getTranslation', () => {
it('returns a target with the state "new" by default', () => {
expect(
i18nService.getTranslation({
id: 'metaDescription.new',
languageCode: 'de'
})
).toBe('New');
});
it('skips a target with the state "new" without a warning if skipUntranslated is true', () => {
expect(
i18nService.getTranslation({
id: 'metaDescription.new',
languageCode: 'de',
skipUntranslated: true
})
).toBe('');
expect(warnSpy).not.toHaveBeenCalled();
});
it('returns a translated target if skipUntranslated is true', () => {
expect(
i18nService.getTranslation({
id: 'metaDescription.translated',
languageCode: 'de',
skipUntranslated: true
})
).toBe('Übersetzt');
});
it('logs a warning for an unknown id by default', () => {
expect(
i18nService.getTranslation({
id: 'metaDescription.unknown',
languageCode: 'de'
})
).toBe('');
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
describe('getLocalizedRouterLink', () => {
it('localizes the tagged segments and keeps the other segments', () => {
expect(
i18nService.getLocalizedRouterLink({
languageCode: 'de',
routerLink: [
'/:kebab-case@@routes.faq:faq',
':kebab-case@@routes.faq.selfHosting:self-hosting',
'saas'
]
})
).toEqual(['haeufig-gestellte-fragen', 'selbst-hosting', 'saas']);
});
});
});

36
apps/api/src/services/i18n/i18n.service.ts

@ -7,6 +7,9 @@ import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService implements OnModuleInit { export class I18nService implements OnModuleInit {
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
private readonly logger = new Logger(I18nService.name); private readonly logger = new Logger(I18nService.name);
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
@ -16,14 +19,37 @@ export class I18nService implements OnModuleInit {
this.loadFiles(); this.loadFiles();
} }
public getLocalizedRouterLink({
languageCode,
routerLink
}: {
languageCode: string;
routerLink: string[];
}): string[] {
return routerLink.map((link) => {
const match = link.match(I18nService.TRANSLATION_TAGGED_MESSAGE_REGEX);
const segment = match
? this.getTranslation({
languageCode,
id: match.groups.id
}) || match.groups.message
: link;
return segment.replace(/^\/+|\/+$/g, '');
});
}
public getTranslation({ public getTranslation({
id, id,
languageCode, languageCode,
placeholders placeholders,
skipUntranslated = false
}: { }: {
id: string; id: string;
languageCode: string; languageCode: string;
placeholders?: Record<string, string | number>; placeholders?: Record<string, string | number>;
skipUntranslated?: boolean;
}): string { }): string {
const languageCodeToUse = this.translations[languageCode] const languageCodeToUse = this.translations[languageCode]
? languageCode ? languageCode
@ -37,13 +63,17 @@ export class I18nService implements OnModuleInit {
return ''; return '';
} }
const targetSelector = skipUntranslated
? 'target:not([state="new"])'
: 'target';
let translatedText = $( let translatedText = $(
`trans-unit[id="${id}"] > ${ `trans-unit[id="${id}"] > ${
languageCodeToUse === DEFAULT_LANGUAGE_CODE ? 'source' : 'target' languageCodeToUse === DEFAULT_LANGUAGE_CODE ? 'source' : targetSelector
}` }`
).text(); ).text();
if (!translatedText) { if (!translatedText && !skipUntranslated) {
this.logger.warn( this.logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'` `Translation not found for id '${id}' in locale '${languageCode}'`
); );

99
apps/client/src/app/pages/i18n/i18n-page.html

@ -8,6 +8,105 @@
worth including cash, stocks, ETFs and cryptocurrencies across multiple worth including cash, stocks, ETFs and cryptocurrencies across multiple
platforms. platforms.
</li> </li>
<li i18n="@@metaDescription.about">
Learn about Ghostfolio, the open source wealth management application to
track stocks, ETFs and cryptocurrencies, and the community behind the
project.
</li>
<li i18n="@@metaDescription.about.changelog">
See what is new in Ghostfolio. The changelog lists the features,
improvements and fixes of each release of the open source wealth
management software.
</li>
<li i18n="@@metaDescription.about.license">
Ghostfolio is open source software under the AGPL-3.0 license. Read the
full license text that applies to the source code of the wealth
management software.
</li>
<li i18n="@@metaDescription.about.ossFriends">
Discover the OSS Friends of Ghostfolio: a collection of other exciting
open source software projects that you can explore and support.
</li>
<li i18n="@@metaDescription.about.privacyPolicy">
Read the Privacy Policy of Ghostfolio to learn how the open source
wealth management software handles your personal data and protects your
privacy.
</li>
<li i18n="@@metaDescription.about.termsOfService">
Read the Terms of Service for the use of Ghostfolio, the web application
and API of the open source software to manage your wealth.
</li>
<li i18n="@@metaDescription.blog">
Read the latest Ghostfolio updates and insights on personal finance:
release announcements, investing topics, community news and open source
milestones.
</li>
<li i18n="@@metaDescription.faq">
Find answers to common questions about Ghostfolio: what the open source
wealth management software does, which assets it tracks and how it
protects privacy.
</li>
<li i18n="@@metaDescription.faq.saas">
Find answers to common questions about the managed Ghostfolio cloud
offering: how to sign up, what you get for free and what Ghostfolio
Premium includes.
</li>
<li i18n="@@metaDescription.faq.selfHosting">
Find answers to common questions about self-hosting Ghostfolio: how to
start, which home server systems are available and how to add a new
currency.
</li>
<li i18n="@@metaDescription.features">
Explore the features of Ghostfolio: track stocks, ETFs, bonds,
cryptocurrencies, dividends and liabilities, and analyze your portfolio
with open source tools.
</li>
<li i18n="@@metaDescription.markets">
Check the current mood of the financial markets with the Fear and Greed
Index and compare the trends of major benchmarks in Ghostfolio.
</li>
<li i18n="@@metaDescription.openStartup">
Ghostfolio is an Open Startup: see the public key metrics of the
platform, including active users, GitHub stars, Docker Hub pulls and
uptime.
</li>
<li i18n="@@metaDescription.pricing">
Compare the plans of Ghostfolio: run the open source software on your
own infrastructure for free or get the managed Ghostfolio Premium cloud
offering.
</li>
<li i18n="@@metaDescription.register">
Create your account and get started with Ghostfolio, the open source
software to track stocks, ETFs and cryptocurrencies.
</li>
<li i18n="@@metaDescription.resources">
Explore the Ghostfolio resources on personal finance and investing:
guides, a glossary, market tools and comparisons of personal finance
tools.
</li>
<li i18n="@@metaDescription.resources.glossary">
Learn the key terms of personal finance and investing in the Ghostfolio
glossary, from buy and hold and dollar-cost averaging to FIRE and
stagflation.
</li>
<li i18n="@@metaDescription.resources.guides">
Get started with investing with the Ghostfolio guides: a diversified,
low-cost strategy and the steps to get your personal finances in order.
</li>
<li i18n="@@metaDescription.resources.markets">
Follow the financial markets with tools such as the Crypto Coins
Heatmap, the Fear and Greed Index, the Inflation Chart and the Stock
Heatmap.
</li>
<li i18n="@@metaDescription.resources.personalFinanceTools">
Discover open source alternatives for personal finance tools. Compare
popular apps with Ghostfolio and take control of your finances, data and
privacy.
</li>
<li i18n="@@metaDescription.resources.personalFinanceTools.product">
Compare Ghostfolio vs $&#123;name&#125;: the open source alternative on
features, data privacy and pricing.
</li>
<li i18n="@@metaKeywords"> <li i18n="@@metaKeywords">
app, asset, cryptocurrency, dashboard, etf, finance, management, app, asset, cryptocurrency, dashboard, etf, finance, management,
performance, portfolio, software, stock, trading, wealth, web3 performance, portfolio, software, stock, trading, wealth, web3

Loading…
Cancel
Save