diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcec2f428..b3be9dd3cf 100644 --- a/CHANGELOG.md +++ b/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 - 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_ - Improved the language localization for German (`de`) diff --git a/apps/api/src/app/endpoints/sitemap/sitemap.service.ts b/apps/api/src/app/endpoints/sitemap/sitemap.service.ts index 6b3a99a8ba..c80fc260f3 100644 --- a/apps/api/src/app/endpoints/sitemap/sitemap.service.ts +++ b/apps/api/src/app/endpoints/sitemap/sitemap.service.ts @@ -9,9 +9,6 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class SitemapService { - private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX = - /:.*@@(?[a-zA-Z0-9.]+):(?.+)/; - public constructor( private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService @@ -148,26 +145,13 @@ export class SitemapService { const rootUrl = this.configurationService.get('ROOT_URL'); return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { - const resourcesPath = this.i18nService.getTranslation({ - languageCode, - id: publicRoutes.resources.path.match( - SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX - ).groups.id - }); - - 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 - }); + const [resourcesPath, personalFinanceToolsPath, productPath] = + this.i18nService.getLocalizedRouterLink({ + languageCode, + routerLink: + publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes + .product.routerLink + }); return personalFinanceTools.map(({ alias, key }) => { const routerLink = [ @@ -217,21 +201,10 @@ export class SitemapService { rootUrl: string; route?: PublicRoute; }): string { - const segments = - route?.routerLink.map((link) => { - const match = link.match( - SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX - ); - - const segment = match - ? (this.i18nService.getTranslation({ - languageCode, - id: match.groups.id - }) ?? match.groups.message) - : link; - - return segment.replace(/^\/+|\/+$/, ''); - }) ?? []; + const segments = this.i18nService.getLocalizedRouterLink({ + languageCode, + routerLink: route?.routerLink ?? [] + }); const location = [rootUrl, languageCode, ...segments].join('/'); diff --git a/apps/api/src/middlewares/html-template.middleware.spec.ts b/apps/api/src/middlewares/html-template.middleware.spec.ts new file mode 100644 index 0000000000..1af1e53abd --- /dev/null +++ b/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 = ''; +const productPath = + '/en/resources/personal-finance-tools/open-source-alternative-to-8figures'; + +function createLocale({ + pricingDescription = 'Pricing description', + productDescription +}: { + pricingDescription?: string; + productDescription: string; +}) { + return ` + + + ${genericDescription} + + + ${pricingDescription} + + + ${productDescription} + + + pricing + + + resources + + + personal-finance-tools + + + open-source-alternative-to + + + `; +} + +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 "plans" & prices', + productDescription: 'Compare ${name}' + }), + originalUrl: '/en/pricing' + }) + ).toContain('content="Compare "plans" & prices"'); + }); +}); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index 09e9af68f8..e148e8e255 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -6,6 +6,9 @@ import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; 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 { format } from 'date-fns'; @@ -16,75 +19,135 @@ import { join } from 'node:path'; const title = 'Ghostfolio'; 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': { + 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', 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': { + 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', title: `500 Stars - ${title}` }, '/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', 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': { + 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', title: `The importance of tracking your personal finances - ${title}` }, '/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', title: `Ghostfolio meets Umbrel - ${title}` }, '/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', title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}` }, '/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', title: `Unlock your Financial Potential with Ghostfolio - ${title}` }, '/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', title: `Exploring the Path to FIRE - ${title}` }, '/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', title: `Ghostfolio joins OSS Friends - ${title}` }, '/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', title: `Announcing Ghostfolio 2.0 - ${title}` }, '/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', title: `Hacktoberfest 2023 - ${title}` }, '/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', title: `Black Week 2023 - ${title}` }, '/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', title: `Hacktoberfest 2023 Debriefing - ${title}` }, '/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', title: `Hacktoberfest 2024 - ${title}` }, '/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', title: `Black Weeks 2024 - ${title}` }, '/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', title: `Hacktoberfest 2025 - ${title}` }, '/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', title: `Black Weeks 2025 - ${title}` }, '/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', title: `Announcing Ghostfolio 3.0 - ${title}` }, @@ -101,6 +164,9 @@ export class HtmlTemplateMiddleware implements NestMiddleware { private readonly logger = new Logger(HtmlTemplateMiddleware.name); private indexHtmlMap: { [languageCode: string]: string } = {}; + private routeDescriptionsMap: { + [languageCode: string]: { [path: string]: string }; + } = {}; public constructor(private readonly i18nService: I18nService) { if (!environment.production) { @@ -135,7 +201,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware { } 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); if ( @@ -162,12 +228,14 @@ export class HtmlTemplateMiddleware implements NestMiddleware { languageCode, path, rootUrl, - description: + description: this.escapeAttribute( locales[path]?.description ?? - this.i18nService.getTranslation({ - languageCode, - id: 'metaDescription' - }), + this.getRouteDescription({ languageCode, path }) ?? + this.i18nService.getTranslation({ + languageCode, + id: 'metaDescription' + }) + ), featureGraphicPath: locales[path]?.featureGraphicPath ?? 'assets/cover.png', keywords: this.i18nService.getTranslation({ @@ -186,6 +254,112 @@ export class HtmlTemplateMiddleware implements NestMiddleware { } } + private escapeAttribute(value: string) { + return value.replace(/&/g, '&').replace(/"/g, '"'); + } + + 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, 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) { if (filename === '/assets/LICENSE') { return true; diff --git a/apps/api/src/services/i18n/i18n.service.spec.ts b/apps/api/src/services/i18n/i18n.service.spec.ts new file mode 100644 index 0000000000..611977bd87 --- /dev/null +++ b/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 = ` + + + New + New + + + Translated + Übersetzt + + + faq + haeufig-gestellte-fragen + + + self-hosting + selbst-hosting + + +`; + +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']); + }); + }); +}); diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts index cd8f85369c..834b0a65e1 100644 --- a/apps/api/src/services/i18n/i18n.service.ts +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -7,6 +7,9 @@ import { join } from 'node:path'; @Injectable() export class I18nService implements OnModuleInit { + private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX = + /:.*@@(?[a-zA-Z0-9.]+):(?.+)/; + private readonly logger = new Logger(I18nService.name); private localesPath = join(__dirname, 'assets', 'locales'); @@ -16,14 +19,37 @@ export class I18nService implements OnModuleInit { 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({ id, languageCode, - placeholders + placeholders, + skipUntranslated = false }: { id: string; languageCode: string; placeholders?: Record; + skipUntranslated?: boolean; }): string { const languageCodeToUse = this.translations[languageCode] ? languageCode @@ -37,13 +63,17 @@ export class I18nService implements OnModuleInit { return ''; } + const targetSelector = skipUntranslated + ? 'target:not([state="new"])' + : 'target'; + let translatedText = $( `trans-unit[id="${id}"] > ${ - languageCodeToUse === DEFAULT_LANGUAGE_CODE ? 'source' : 'target' + languageCodeToUse === DEFAULT_LANGUAGE_CODE ? 'source' : targetSelector }` ).text(); - if (!translatedText) { + if (!translatedText && !skipUntranslated) { this.logger.warn( `Translation not found for id '${id}' in locale '${languageCode}'` ); diff --git a/apps/client/src/app/pages/i18n/i18n-page.html b/apps/client/src/app/pages/i18n/i18n-page.html index 707c3a0b4e..9c1f0b25fa 100644 --- a/apps/client/src/app/pages/i18n/i18n-page.html +++ b/apps/client/src/app/pages/i18n/i18n-page.html @@ -8,6 +8,105 @@ worth including cash, stocks, ETFs and cryptocurrencies across multiple platforms. +
  • + Learn about Ghostfolio, the open source wealth management application to + track stocks, ETFs and cryptocurrencies, and the community behind the + project. +
  • +
  • + See what is new in Ghostfolio. The changelog lists the features, + improvements and fixes of each release of the open source wealth + management software. +
  • +
  • + 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. +
  • +
  • + Discover the OSS Friends of Ghostfolio: a collection of other exciting + open source software projects that you can explore and support. +
  • +
  • + Read the Privacy Policy of Ghostfolio to learn how the open source + wealth management software handles your personal data and protects your + privacy. +
  • +
  • + Read the Terms of Service for the use of Ghostfolio, the web application + and API of the open source software to manage your wealth. +
  • +
  • + Read the latest Ghostfolio updates and insights on personal finance: + release announcements, investing topics, community news and open source + milestones. +
  • +
  • + Find answers to common questions about Ghostfolio: what the open source + wealth management software does, which assets it tracks and how it + protects privacy. +
  • +
  • + 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. +
  • +
  • + 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. +
  • +
  • + Explore the features of Ghostfolio: track stocks, ETFs, bonds, + cryptocurrencies, dividends and liabilities, and analyze your portfolio + with open source tools. +
  • +
  • + Check the current mood of the financial markets with the Fear and Greed + Index and compare the trends of major benchmarks in Ghostfolio. +
  • +
  • + Ghostfolio is an Open Startup: see the public key metrics of the + platform, including active users, GitHub stars, Docker Hub pulls and + uptime. +
  • +
  • + Compare the plans of Ghostfolio: run the open source software on your + own infrastructure for free or get the managed Ghostfolio Premium cloud + offering. +
  • +
  • + Create your account and get started with Ghostfolio, the open source + software to track stocks, ETFs and cryptocurrencies. +
  • +
  • + Explore the Ghostfolio resources on personal finance and investing: + guides, a glossary, market tools and comparisons of personal finance + tools. +
  • +
  • + 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. +
  • +
  • + Get started with investing with the Ghostfolio guides: a diversified, + low-cost strategy and the steps to get your personal finances in order. +
  • +
  • + Follow the financial markets with tools such as the Crypto Coins + Heatmap, the Fear and Greed Index, the Inflation Chart and the Stock + Heatmap. +
  • +
  • + Discover open source alternatives for personal finance tools. Compare + popular apps with Ghostfolio and take control of your finances, data and + privacy. +
  • +
  • + Compare Ghostfolio vs ${name}: the open source alternative on + features, data privacy and pricing. +
  • app, asset, cryptocurrency, dashboard, etf, finance, management, performance, portfolio, software, stock, trading, wealth, web3