From ea30c3964894758d96a7a32a70e1e06d989fd471 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 08:58:34 +0000 Subject: [PATCH 1/3] feat: improve on-page SEO with per-page metadata and structured data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add route-specific titles, descriptions, canonicals, and Open Graph/Twitter tags, plus JSON-LD for Organization, FAQ, and services. Improve heading markup, crawlable service links, sitemap freshness, and 404 noindex status without changing page copy or inventing claims. Co-authored-by: ✅ Daniel Danielecki --- e2e/seo.spec.ts | 40 +++ src/app/app.component.spec.ts | 4 +- src/app/app.component.ts | 21 +- .../about-us/about-us.component.html | 6 +- .../components/contact/contact.component.html | 6 +- .../copyrights/copyrights.component.html | 4 +- src/app/components/faq/faq.component.html | 4 +- .../components/footer/footer.component.html | 3 +- .../glossary/glossary.component.html | 4 +- .../components/header/header.component.html | 2 +- .../home/gallery/gallery.component.html | 9 +- .../home/gallery/gallery.component.spec.ts | 5 +- .../home/gallery/gallery.component.ts | 2 +- .../components/home/hero/hero.component.html | 2 +- .../home/hero/hero.component.spec.ts | 3 +- .../testimonials/testimonials.component.html | 6 +- .../methodology/methodology.component.html | 6 +- .../not-found/not-found.component.html | 5 +- .../not-found/not-found.component.scss | 6 + .../not-found/not-found.component.spec.ts | 3 +- .../not-found/not-found.component.ts | 3 +- .../partnerships/partnerships.component.html | 6 +- .../partnerships.component.spec.ts | 5 +- .../privacy-and-security.component.html | 4 +- .../privacy-and-security.component.spec.ts | 2 +- .../service-detail.component.html | 10 +- .../service-detail.component.spec.ts | 2 +- .../services/services.component.html | 38 +-- .../services/services.component.spec.ts | 24 +- .../components/sitemap/sitemap.component.html | 12 +- .../components/sitemap/sitemap.component.scss | 7 +- .../sitemap/sitemap.component.spec.ts | 9 +- .../components/sitemap/sitemap.component.ts | 1 + .../terms-of-use/terms-of-use.component.html | 4 +- src/app/data/index.ts | 1 + src/app/data/seo.data.ts | 218 ++++++++++++++++ src/app/services/seo.service.spec.ts | 110 ++++++++ src/app/services/seo.service.ts | 234 ++++++++++++++++++ src/index.html | 76 ++---- src/robots.txt | 6 +- src/server.ts | 5 + src/sitemap.xml | 17 +- src/styles.scss | 11 + 43 files changed, 781 insertions(+), 165 deletions(-) create mode 100644 e2e/seo.spec.ts create mode 100644 src/app/data/seo.data.ts create mode 100644 src/app/services/seo.service.spec.ts create mode 100644 src/app/services/seo.service.ts diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts new file mode 100644 index 0000000..7284058 --- /dev/null +++ b/e2e/seo.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +test('home page has indexable title, description, and canonical', async ({ + page, +}) => { + await page.goto('/'); + await expect(page.locator('app-root')).toBeVisible({ timeout: 15000 }); + await expect(page).toHaveTitle(/Ditectrev/i); + await expect(page.locator('meta[name="description"]').first()).toHaveAttribute( + 'content', + /online education/i + ); + await expect(page.locator('link[rel="canonical"]').first()).toHaveAttribute( + 'href', + /https:\/\/ditectrev\.com\/?$/ + ); +}); + +test('about-us page uses a unique title and self-canonical', async ({ + page, +}) => { + await page.goto('/about-us'); + await expect(page.locator('app-root')).toBeVisible({ timeout: 15000 }); + await expect(page).toHaveTitle(/About Us/i); + await expect(page.locator('link[rel="canonical"]').first()).toHaveAttribute( + 'href', + /\/about-us$/ + ); +}); + +test('unknown routes are marked noindex', async ({ page }) => { + await page.goto('/this-page-does-not-exist'); + await expect(page.locator('app-root')).toBeVisible({ timeout: 15000 }); + await expect(page).toHaveTitle(/Page Not Found/i); + await expect(page.locator('meta[name="robots"]').first()).toHaveAttribute( + 'content', + /noindex/i + ); + await expect(page.getByRole('link', { name: /homepage/i })).toBeVisible(); +}); diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index c94803a..aef8314 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -2,6 +2,7 @@ import 'hammerjs'; import { AppComponent } from './app.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Meta, Title } from '@angular/platform-browser'; import { HomeModule } from './home/home.module'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; @@ -18,7 +19,8 @@ describe('AppComponent', () => { HomeModule, HttpClientTestingModule, RouterTestingModule, - ] + ], + providers: [Title, Meta] }).compileComponents(); })); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b414771..40ee4a0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,8 +1,11 @@ import { animate, style, transition, trigger } from '@angular/animations'; -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs/operators'; import { HeaderComponent } from './components/header/header.component'; import { FooterComponent } from './components/footer/footer.component'; +import { SeoService } from './services/seo.service'; // TODO: Outsource this to constants. const FADE_ANIMATION = @@ -26,6 +29,20 @@ const FADE_ANIMATION = // TODO: Add unit tests coverage for these methods. // TODO: Uncomment ngx-spinner and fix it on Angular Universal, because now it's breaking. export class AppComponent { + private readonly router = inject(Router); + private readonly seoService = inject(SeoService); + + constructor() { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed() + ) + .subscribe((event) => { + this.seoService.updateForUrl(event.urlAfterRedirects); + }); + } + public getRouterOutletState(routerOutlet: RouterOutlet): string { const routeData = routerOutlet.activatedRouteData['animation']; return routeData ? routeData : 'rootPage'; diff --git a/src/app/components/about-us/about-us.component.html b/src/app/components/about-us/about-us.component.html index 47ff850..b81911b 100644 --- a/src/app/components/about-us/about-us.component.html +++ b/src/app/components/about-us/about-us.component.html @@ -6,10 +6,10 @@ role="region" > - About us

About us

- Your Digital Transformation and Innovation Partner. Creativity and Privacy-Preserving Approach describes us best. #cyber Security, #digital Strategy and #software Development. - - Contact +

Contact

- We are Partners, not contractors. How can we help You? diff --git a/src/app/components/copyrights/copyrights.component.html b/src/app/components/copyrights/copyrights.component.html index b02c20b..d78d287 100644 --- a/src/app/components/copyrights/copyrights.component.html +++ b/src/app/components/copyrights/copyrights.component.html @@ -2,8 +2,8 @@ - Copyrights - Before you copy anything from us kindly please +

Copyrights

+ Before you copy anything from us kindly please read it carefully. diff --git a/src/app/components/faq/faq.component.html b/src/app/components/faq/faq.component.html index cc73636..ef9b5b7 100644 --- a/src/app/components/faq/faq.component.html +++ b/src/app/components/faq/faq.component.html @@ -2,8 +2,8 @@ - FAQ - Below you can find basic questions regarding cooperations, our +

FAQ

+ Below you can find basic questions regarding cooperations, our company and services we provide. If you still cannot find an answer on your question do not hesitate to contact us. diff --git a/src/app/components/footer/footer.component.html b/src/app/components/footer/footer.component.html index 6d89338..e854f30 100644 --- a/src/app/components/footer/footer.component.html +++ b/src/app/components/footer/footer.component.html @@ -8,7 +8,8 @@ This is Ditectrev's logo diff --git a/src/app/components/glossary/glossary.component.html b/src/app/components/glossary/glossary.component.html index cb280fd..74927b1 100644 --- a/src/app/components/glossary/glossary.component.html +++ b/src/app/components/glossary/glossary.component.html @@ -2,8 +2,8 @@ - Glossary - Please use the tables of terms with sorting and filtration in order +

Glossary

+ Please use the tables of terms with sorting and filtration in order to find what is unclear for you. diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index 5f5ba3d..e8ccbc9 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -20,7 +20,7 @@
This is Ditectrev's logo diff --git a/src/app/components/home/gallery/gallery.component.html b/src/app/components/home/gallery/gallery.component.html index 85ddc52..ad4d596 100644 --- a/src/app/components/home/gallery/gallery.component.html +++ b/src/app/components/home/gallery/gallery.component.html @@ -5,14 +5,15 @@

What can we do for You?

diff --git a/src/app/components/home/gallery/gallery.component.spec.ts b/src/app/components/home/gallery/gallery.component.spec.ts index f1ad1ba..9db15fe 100644 --- a/src/app/components/home/gallery/gallery.component.spec.ts +++ b/src/app/components/home/gallery/gallery.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from "@angular/core/testing"; import { GalleryComponent } from "./gallery.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { RouterTestingModule } from "@angular/router/testing"; describe("GalleryComponent", () => { let component: GalleryComponent; @@ -8,7 +9,7 @@ describe("GalleryComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [GalleryComponent, BrowserAnimationsModule], + imports: [GalleryComponent, BrowserAnimationsModule, RouterTestingModule], }).compileComponents(); fixture = TestBed.createComponent(GalleryComponent); @@ -191,7 +192,7 @@ describe("GalleryComponent", () => { describe("Images Data", () => { it("should have correct image sources", () => { - expect(component.images[0].src).toBe('./assets/cyber-security.avif'); + expect(component.images[0].src).toBe('assets/cyber-security.avif'); expect(component.images[1].src).toBe('assets/digital-strategy.avif'); expect(component.images[2].src).toBe('assets/software-development.avif'); }); diff --git a/src/app/components/home/gallery/gallery.component.ts b/src/app/components/home/gallery/gallery.component.ts index c57f93e..f99c33f 100644 --- a/src/app/components/home/gallery/gallery.component.ts +++ b/src/app/components/home/gallery/gallery.component.ts @@ -43,7 +43,7 @@ export class GalleryComponent implements OnInit { // TODO: Change href on routerLink, issue #87. images: GalleryImage[] = [ { - src: './assets/cyber-security.avif', + src: 'assets/cyber-security.avif', title: 'Cyber Security', description: 'Learn more about our cyber security services', link: '/services/cyber-security' diff --git a/src/app/components/home/hero/hero.component.html b/src/app/components/home/hero/hero.component.html index c36808f..783ba9b 100644 --- a/src/app/components/home/hero/hero.component.html +++ b/src/app/components/home/hero/hero.component.html @@ -3,7 +3,7 @@

diff --git a/src/app/components/home/hero/hero.component.spec.ts b/src/app/components/home/hero/hero.component.spec.ts index e17ac4a..a3780b4 100644 --- a/src/app/components/home/hero/hero.component.spec.ts +++ b/src/app/components/home/hero/hero.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { DomSanitizer } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; import { HeroComponent } from './hero.component'; // Mock particlesJS @@ -19,7 +20,7 @@ describe('HeroComponent', () => { window.particlesJS = jasmine.createSpy('particlesJS'); await TestBed.configureTestingModule({ - imports: [HeroComponent], + imports: [HeroComponent, RouterTestingModule], providers: [ { provide: DomSanitizer, diff --git a/src/app/components/home/testimonials/testimonials.component.html b/src/app/components/home/testimonials/testimonials.component.html index f7ac7e6..6b577af 100644 --- a/src/app/components/home/testimonials/testimonials.component.html +++ b/src/app/components/home/testimonials/testimonials.component.html @@ -4,9 +4,13 @@

Success stories.

{{ slidesItem.testimonial }} diff --git a/src/app/components/methodology/methodology.component.html b/src/app/components/methodology/methodology.component.html index 4d09b8c..78dbd90 100644 --- a/src/app/components/methodology/methodology.component.html +++ b/src/app/components/methodology/methodology.component.html @@ -6,10 +6,10 @@ role="region" > - Methodology

Methodology

- Modern Delivery approach. diff --git a/src/app/components/not-found/not-found.component.html b/src/app/components/not-found/not-found.component.html index d03dc9a..29c3ada 100644 --- a/src/app/components/not-found/not-found.component.html +++ b/src/app/components/not-found/not-found.component.html @@ -8,12 +8,15 @@ >

-

Page not found. That's an error – please try again. + +

diff --git a/src/app/components/not-found/not-found.component.scss b/src/app/components/not-found/not-found.component.scss index e3fe90a..8bb7c1e 100644 --- a/src/app/components/not-found/not-found.component.scss +++ b/src/app/components/not-found/not-found.component.scss @@ -13,3 +13,9 @@ display: flex; justify-content: center; } + +.error-home-link { + display: flex; + justify-content: center; + margin-top: 1rem; +} diff --git a/src/app/components/not-found/not-found.component.spec.ts b/src/app/components/not-found/not-found.component.spec.ts index b0aa20b..90be1c8 100644 --- a/src/app/components/not-found/not-found.component.spec.ts +++ b/src/app/components/not-found/not-found.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { Renderer2 } from "@angular/core"; +import { RouterTestingModule } from "@angular/router/testing"; import { NotFoundComponent } from "./not-found.component"; describe("NotFoundComponent", () => { @@ -15,7 +16,7 @@ describe("NotFoundComponent", () => { ]); await TestBed.configureTestingModule({ - imports: [NotFoundComponent], + imports: [NotFoundComponent, RouterTestingModule], providers: [ { provide: Renderer2, useValue: mockRenderer2 }, ], diff --git a/src/app/components/not-found/not-found.component.ts b/src/app/components/not-found/not-found.component.ts index 1945d27..b7445ed 100644 --- a/src/app/components/not-found/not-found.component.ts +++ b/src/app/components/not-found/not-found.component.ts @@ -5,6 +5,7 @@ import { Renderer2, ViewChild, } from "@angular/core"; +import { RouterLink } from "@angular/router"; // @ts-ignore import * as THREE from 'three'; @@ -18,7 +19,7 @@ import * as THREE from 'three'; templateUrl: "./not-found.component.html", styleUrls: ["./not-found.component.scss"], standalone: true, - imports: [] + imports: [RouterLink] }) export class NotFoundComponent implements AfterViewInit { @ViewChild("renderIcosahedron", { static: false }) diff --git a/src/app/components/partnerships/partnerships.component.html b/src/app/components/partnerships/partnerships.component.html index 39485dd..68bc204 100644 --- a/src/app/components/partnerships/partnerships.component.html +++ b/src/app/components/partnerships/partnerships.component.html @@ -7,12 +7,12 @@ role="region" > - Partnerships

Partnerships

- We can partner with many entities. diff --git a/src/app/components/partnerships/partnerships.component.spec.ts b/src/app/components/partnerships/partnerships.component.spec.ts index 4040a20..297132b 100644 --- a/src/app/components/partnerships/partnerships.component.spec.ts +++ b/src/app/components/partnerships/partnerships.component.spec.ts @@ -95,8 +95,7 @@ describe('PartnershipsComponent', () => { expect(title).toBeTruthy(); expect(title?.textContent?.trim()).toBe('Partnerships'); - expect(title?.getAttribute('aria-level')).toBe('1'); - expect(title?.getAttribute('role')).toBe('heading'); + expect(title?.querySelector('h1')?.textContent?.trim()).toBe('Partnerships'); expect(title?.getAttribute('title')).toBe('Partnerships'); }); @@ -108,8 +107,6 @@ describe('PartnershipsComponent', () => { expect(subtitle?.textContent?.trim()).toBe( 'We can partner with many entities.' ); - expect(subtitle?.getAttribute('aria-level')).toBe('3'); - expect(subtitle?.getAttribute('role')).toBe('heading'); }); it('should render mat-tab-group', () => { diff --git a/src/app/components/privacy-and-security/privacy-and-security.component.html b/src/app/components/privacy-and-security/privacy-and-security.component.html index a7668d5..5af42c2 100644 --- a/src/app/components/privacy-and-security/privacy-and-security.component.html +++ b/src/app/components/privacy-and-security/privacy-and-security.component.html @@ -2,8 +2,8 @@ - Privacy & Security - We respect your privacy, but nowadays to exists we have to use some +

Privacy & Security

+ We respect your privacy, but nowadays to exists we have to use some of your data. diff --git a/src/app/components/privacy-and-security/privacy-and-security.component.spec.ts b/src/app/components/privacy-and-security/privacy-and-security.component.spec.ts index 2e234b0..7b0ba78 100644 --- a/src/app/components/privacy-and-security/privacy-and-security.component.spec.ts +++ b/src/app/components/privacy-and-security/privacy-and-security.component.spec.ts @@ -189,7 +189,7 @@ describe('PrivacyAndSecurityComponent', () => { fixture.detectChanges(); const compiled = fixture.nativeElement; - const titleHeading = compiled.querySelector('mat-card-title[role="heading"], [aria-level="1"]'); + const titleHeading = compiled.querySelector('mat-card-title h1, h1'); const h3s = compiled.querySelectorAll('h3'); expect(titleHeading).toBeTruthy(); expect(h3s.length).toBeGreaterThan(0); diff --git a/src/app/components/services/service-detail/service-detail.component.html b/src/app/components/services/service-detail/service-detail.component.html index a742a6e..aa956ab 100644 --- a/src/app/components/services/service-detail/service-detail.component.html +++ b/src/app/components/services/service-detail/service-detail.component.html @@ -10,19 +10,19 @@

{{ title }}

role="region" > - - {{ serviceItem.name }} + +

{{ serviceItem.name }}

{{ serviceItem.description }} diff --git a/src/app/components/services/service-detail/service-detail.component.spec.ts b/src/app/components/services/service-detail/service-detail.component.spec.ts index 8147d0e..0f9cc88 100644 --- a/src/app/components/services/service-detail/service-detail.component.spec.ts +++ b/src/app/components/services/service-detail/service-detail.component.spec.ts @@ -81,7 +81,7 @@ describe('ServiceDetailComponent', () => { images.forEach((img, index) => { const item = mockServiceItems[index]; - expect(img.getAttribute('alt')).toBe(item.name); + expect(img.getAttribute('alt')).toBe(item.name + ' service'); expect(img.getAttribute('src')).toBe(item.src); expect(img.getAttribute('title')).toBe(item.name); }); diff --git a/src/app/components/services/services.component.html b/src/app/components/services/services.component.html index 56d1e36..4efec6a 100644 --- a/src/app/components/services/services.component.html +++ b/src/app/components/services/services.component.html @@ -1,4 +1,4 @@ -

Services

+

Services

@@ -6,17 +6,17 @@

Services

- Cyber Security +

Cyber Security

- + Data-Driven and Future-Proof Software must come up with Cyber Security, together. - - + + + Read more about Cyber Security +
@@ -24,17 +24,17 @@

Services

- Digital Strategy +

Digital Strategy

- + Best-in-Class Development with Frictionless Digital Experience and Inquisitive Design. - - + + + Read more about Digital Strategy +
Services - Software Development +

Software Development

- + End-to-End solutions with Scaled Architecture and Smooth Development. - - + + + Read more about Software Development +
diff --git a/src/app/components/services/services.component.spec.ts b/src/app/components/services/services.component.spec.ts index cd93f49..e55c037 100644 --- a/src/app/components/services/services.component.spec.ts +++ b/src/app/components/services/services.component.spec.ts @@ -56,23 +56,27 @@ describe('ServicesComponent', () => { ]); }); - it('should render call-to-action buttons for each service', () => { + it('should render call-to-action links for each service', () => { const compiled = fixture.nativeElement as HTMLElement; - const buttons = Array.from(compiled.querySelectorAll('button')).map( - (btn) => btn.textContent?.trim(), + const links = Array.from(compiled.querySelectorAll('a.service-button')).map( + (link) => link.textContent?.trim(), ); - expect(buttons).toEqual(['Read more', 'Read more', 'Read more']); + expect(links).toEqual([ + 'Read more about Cyber Security', + 'Read more about Digital Strategy', + 'Read more about Software Development', + ]); }); - it('should have correct router links on buttons', () => { + it('should have correct router links on service call-to-action links', () => { const compiled = fixture.nativeElement as HTMLElement; - const buttons = compiled.querySelectorAll('button[routerLink]'); + const links = compiled.querySelectorAll('a.service-button'); - expect(buttons.length).toBe(3); - expect(buttons[0].getAttribute('routerLink')).toBe('/services/cyber-security'); - expect(buttons[1].getAttribute('routerLink')).toBe('/services/digital-strategy'); - expect(buttons[2].getAttribute('routerLink')).toBe('/services/software-development'); + expect(links.length).toBe(3); + expect(links[0].getAttribute('routerLink')).toBe('/services/cyber-security'); + expect(links[1].getAttribute('routerLink')).toBe('/services/digital-strategy'); + expect(links[2].getAttribute('routerLink')).toBe('/services/software-development'); }); it('should render torus knot animation element', () => { diff --git a/src/app/components/sitemap/sitemap.component.html b/src/app/components/sitemap/sitemap.component.html index 398853c..bd9e1a6 100644 --- a/src/app/components/sitemap/sitemap.component.html +++ b/src/app/components/sitemap/sitemap.component.html @@ -2,15 +2,15 @@ - Sitemap - This is a dedicated website for our clients. This site allows you +

Sitemap

+ This is a dedicated website for our clients. This site allows you to see all resources available across our website.
- Company +

Company

- Services +

Services

- Information +

Information

- Resources +

Resources

- Terms of Use - Here you can find essential terms which apply. +

Terms of Use

+ Here you can find essential terms which apply.
diff --git a/src/app/data/index.ts b/src/app/data/index.ts index c3f79f5..a861a2f 100644 --- a/src/app/data/index.ts +++ b/src/app/data/index.ts @@ -1 +1,2 @@ export * from './glossary.data'; +export * from './seo.data'; diff --git a/src/app/data/seo.data.ts b/src/app/data/seo.data.ts new file mode 100644 index 0000000..d75b5de --- /dev/null +++ b/src/app/data/seo.data.ts @@ -0,0 +1,218 @@ +export const SITE_ORIGIN = 'https://ditectrev.com'; +export const SITE_NAME = 'Ditectrev'; +export const DEFAULT_OG_IMAGE = `${SITE_ORIGIN}/assets/icons/icon-512x512.png`; +export const DEFAULT_OG_IMAGE_ALT = "Ditectrev's logo"; + +export const INDEXABLE_PATHS: readonly string[] = [ + '/', + '/about-us', + '/contact', + '/methodology', + '/services', + '/faq', + '/glossary', + '/sitemap', + '/copyrights', + '/terms-of-use', + '/privacy-and-security', + '/partnerships', + '/services/cyber-security', + '/services/digital-strategy', + '/services/software-development', +]; + +export type PageJsonLdType = + | 'WebSite' + | 'AboutPage' + | 'ContactPage' + | 'FAQPage' + | 'Service' + | 'WebPage'; + +export interface SeoBreadcrumb { + name: string; + path: string; +} + +export interface PageSeo { + title: string; + description: string; + robots?: string; + jsonLdType: PageJsonLdType; + breadcrumbs: SeoBreadcrumb[]; + serviceName?: string; +} + +const homeCrumb: SeoBreadcrumb = { name: 'Home', path: '/' }; +const servicesCrumb: SeoBreadcrumb = { name: 'Services', path: '/services' }; + +export const PAGE_SEO: Record = { + '/': { + title: 'Ditectrev – Online Education and IT Consulting', + description: + 'Information Technology (IT) services company focused on consulting and online education in cyber security, digital strategy, and software development.', + jsonLdType: 'WebSite', + breadcrumbs: [homeCrumb], + }, + '/about-us': { + title: 'About Us | Ditectrev', + description: + 'Your digital transformation and innovation partner. Creativity and a privacy-preserving approach in cyber security, digital strategy, and software development.', + jsonLdType: 'AboutPage', + breadcrumbs: [homeCrumb, { name: 'About us', path: '/about-us' }], + }, + '/contact': { + title: 'Contact | Ditectrev', + description: + 'We are partners, not contractors. Contact Ditectrev about online education and IT consulting.', + jsonLdType: 'ContactPage', + breadcrumbs: [homeCrumb, { name: 'Contact', path: '/contact' }], + }, + '/methodology': { + title: 'Methodology | Ditectrev', + description: + 'Modern delivery approach. Ditectrev works with Scrum, delivering small parts often and involving you at every project stage.', + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Methodology', path: '/methodology' }], + }, + '/services': { + title: 'Services | Ditectrev', + description: + 'Ditectrev services in cyber security, digital strategy, and software development, from data-driven software to end-to-end solutions.', + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, servicesCrumb], + }, + '/faq': { + title: 'FAQ | Ditectrev', + description: + "Answers to common questions about Ditectrev's company, cooperation, education, and IT services.", + jsonLdType: 'FAQPage', + breadcrumbs: [homeCrumb, { name: 'FAQ', path: '/faq' }], + }, + '/glossary': { + title: 'Glossary | Ditectrev', + description: + "Tables of terms used in Ditectrev's cyber security, digital strategy, and software development services.", + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Glossary', path: '/glossary' }], + }, + '/sitemap': { + title: 'Sitemap | Ditectrev', + description: + 'A directory of pages on ditectrev.com, including company, services, information, and resources.', + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Sitemap', path: '/sitemap' }], + }, + '/copyrights': { + title: 'Copyrights | Ditectrev', + description: + 'Copyright information for Ditectrev content, including what you may and may not copy from our website and assets.', + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Copyrights', path: '/copyrights' }], + }, + '/terms-of-use': { + title: 'Terms of Use | Ditectrev', + description: + "Essential terms that apply to using Ditectrev's website and assets.", + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Terms of Use', path: '/terms-of-use' }], + }, + '/privacy-and-security': { + title: 'Privacy & Security | Ditectrev', + description: + 'How Ditectrev protects your privacy and uses personal data when you use our website.', + jsonLdType: 'WebPage', + breadcrumbs: [ + homeCrumb, + { name: 'Privacy & Security', path: '/privacy-and-security' }, + ], + }, + '/partnerships': { + title: 'Partnerships | Ditectrev', + description: + 'Partner with Ditectrev as a creative agency, freelancer, or software house.', + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Partnerships', path: '/partnerships' }], + }, + '/services/cyber-security': { + title: 'Cyber Security Services | Ditectrev', + description: + 'Data-driven and future-proof software together with cyber security: compliance audits, ethical hacking, secure programming, and more.', + jsonLdType: 'Service', + serviceName: 'Cyber Security', + breadcrumbs: [ + homeCrumb, + servicesCrumb, + { name: 'Cyber Security', path: '/services/cyber-security' }, + ], + }, + '/services/digital-strategy': { + title: 'Digital Strategy Services | Ditectrev', + description: + 'Best-in-class development with frictionless digital experience: business development, digital marketing, product design, and more.', + jsonLdType: 'Service', + serviceName: 'Digital Strategy', + breadcrumbs: [ + homeCrumb, + servicesCrumb, + { name: 'Digital Strategy', path: '/services/digital-strategy' }, + ], + }, + '/services/software-development': { + title: 'Software Development Services | Ditectrev', + description: + 'End-to-end solutions with scaled architecture and smooth development: web, mobile, cloud, eCommerce, and more.', + jsonLdType: 'Service', + serviceName: 'Software Development', + breadcrumbs: [ + homeCrumb, + servicesCrumb, + { name: 'Software Development', path: '/services/software-development' }, + ], + }, +}; + +export const NOT_FOUND_SEO: PageSeo = { + title: 'Page Not Found | Ditectrev', + description: + 'The page you requested was not found. Return to the Ditectrev homepage to continue.', + robots: 'noindex, follow', + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Page not found', path: '' }], +}; + +export const ORGANIZATION_SAME_AS: readonly string[] = [ + 'https://discord.com/invite/RFjtXKfJy3', + 'https://www.facebook.com/ditectrev', + 'https://github.com/ditectrev', + 'https://www.instagram.com/ditectrev', + 'https://www.linkedin.com/company/ditectrev', + 'https://medium.com/@ditectrev', + 'https://x.com/ditectrev', + 'https://www.youtube.com/@Ditectrev', +]; + +export function canonicalizePath(rawPath: string): string { + const withoutQuery = rawPath.split('?')[0].split('#')[0]; + if (!withoutQuery || withoutQuery === '/') { + return '/'; + } + return withoutQuery.length > 1 && withoutQuery.endsWith('/') + ? withoutQuery.slice(0, -1) + : withoutQuery; +} + +export function absoluteUrl(path: string): string { + if (!path || path === '/') { + return `${SITE_ORIGIN}/`; + } + return `${SITE_ORIGIN}${path.startsWith('/') ? path : `/${path}`}`; +} + +export function isIndexablePath(rawPath: string): boolean { + return INDEXABLE_PATHS.includes(canonicalizePath(rawPath)); +} + +export function getPageSeo(rawPath: string): PageSeo { + return PAGE_SEO[canonicalizePath(rawPath)] ?? NOT_FOUND_SEO; +} diff --git a/src/app/services/seo.service.spec.ts b/src/app/services/seo.service.spec.ts new file mode 100644 index 0000000..a6b849d --- /dev/null +++ b/src/app/services/seo.service.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from '@angular/core/testing'; +import { Meta, Title } from '@angular/platform-browser'; +import { SeoService } from './seo.service'; +import { + DEFAULT_OG_IMAGE, + NOT_FOUND_SEO, + SITE_ORIGIN, + canonicalizePath, + getPageSeo, + isIndexablePath, +} from '../data/seo.data'; + +describe('SeoService', () => { + let service: SeoService; + let title: Title; + let meta: Meta; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [SeoService, Title, Meta], + }); + service = TestBed.inject(SeoService); + title = TestBed.inject(Title); + meta = TestBed.inject(Meta); + }); + + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + it('should set unique title, description, canonical, and robots for a known page', () => { + service.updateForUrl('/about-us?utm=test'); + + expect(title.getTitle()).toBe('About Us | Ditectrev'); + expect(meta.getTag('name="description"')?.content).toContain( + 'digital transformation' + ); + expect(meta.getTag('name="robots"')?.content).toBe('index,follow'); + expect(meta.getTag('property="og:url"')?.content).toBe( + `${SITE_ORIGIN}/about-us` + ); + expect(meta.getTag('property="og:image"')?.content).toBe(DEFAULT_OG_IMAGE); + expect(meta.getTag('name="twitter:card"')?.content).toBe('summary'); + + const canonical = document.querySelector('link[rel="canonical"]'); + expect(canonical?.getAttribute('href')).toBe(`${SITE_ORIGIN}/about-us`); + }); + + it('should noindex unknown routes and keep a self-referencing canonical', () => { + service.updateForUrl('/this-page-does-not-exist'); + + expect(title.getTitle()).toBe(NOT_FOUND_SEO.title); + expect(meta.getTag('name="robots"')?.content).toBe('noindex, follow'); + expect( + document.querySelector('link[rel="canonical"]')?.getAttribute('href') + ).toBe(`${SITE_ORIGIN}/this-page-does-not-exist`); + }); + + it('should inject Organization, WebSite, and BreadcrumbList JSON-LD', () => { + service.updateForUrl('/'); + + const script = document.getElementById('ditectrev-jsonld'); + expect(script).toBeTruthy(); + const parsed = JSON.parse(script?.textContent ?? '{}'); + const types = parsed['@graph'].map((node: { '@type': unknown }) => node['@type']); + expect(types).toContain('WebSite'); + expect(JSON.stringify(types)).toContain('Organization'); + expect(types).toContain('BreadcrumbList'); + }); + + it('should inject FAQPage JSON-LD with question and answer pairs from existing FAQ copy', () => { + service.updateForUrl('/faq'); + + const script = document.getElementById('ditectrev-jsonld'); + const parsed = JSON.parse(script?.textContent ?? '{}'); + const faq = parsed['@graph'].find( + (node: { '@type': string }) => node['@type'] === 'FAQPage' + ); + expect(faq).toBeDefined(); + expect(faq.mainEntity.length).toBeGreaterThan(5); + expect(faq.mainEntity[0]['@type']).toBe('Question'); + expect(faq.mainEntity[0].acceptedAnswer['@type']).toBe('Answer'); + expect(faq.mainEntity[0].acceptedAnswer.text).not.toContain(' { + service.updateForUrl('/services/cyber-security'); + + const script = document.getElementById('ditectrev-jsonld'); + const parsed = JSON.parse(script?.textContent ?? '{}'); + const serviceNode = parsed['@graph'].find( + (node: { '@type': string }) => node['@type'] === 'Service' + ); + expect(serviceNode?.name).toBe('Cyber Security'); + expect(serviceNode?.url).toBe(`${SITE_ORIGIN}/services/cyber-security`); + }); +}); + +describe('seo data helpers', () => { + it('should canonicalize trailing slashes and query strings', () => { + expect(canonicalizePath('/about-us/?x=1')).toBe('/about-us'); + expect(canonicalizePath('/')).toBe('/'); + }); + + it('should treat listed routes as indexable and others as not', () => { + expect(isIndexablePath('/services')).toBe(true); + expect(isIndexablePath('/missing')).toBe(false); + expect(getPageSeo('/missing').robots).toContain('noindex'); + }); +}); diff --git a/src/app/services/seo.service.ts b/src/app/services/seo.service.ts new file mode 100644 index 0000000..e090cbd --- /dev/null +++ b/src/app/services/seo.service.ts @@ -0,0 +1,234 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; +import { FaqQuestions } from '@interfaces'; +import { FAQ_QUESTIONS } from '../components/faq/faq.component'; +import { + DEFAULT_OG_IMAGE, + DEFAULT_OG_IMAGE_ALT, + ORGANIZATION_SAME_AS, + SITE_NAME, + SITE_ORIGIN, + absoluteUrl, + canonicalizePath, + getPageSeo, + type PageSeo, +} from '../data/seo.data'; + +const JSON_LD_SCRIPT_ID = 'ditectrev-jsonld'; +const ORGANIZATION_ID = `${SITE_ORIGIN}/#organization`; +const WEBSITE_ID = `${SITE_ORIGIN}/#website`; + +@Injectable({ providedIn: 'root' }) +export class SeoService { + constructor( + private readonly title: Title, + private readonly meta: Meta, + @Inject(DOCUMENT) private readonly document: Document + ) {} + + public updateForUrl(rawUrl: string): void { + const path = canonicalizePath(rawUrl); + const page = getPageSeo(path); + const canonical = absoluteUrl(path === '/' ? '/' : path); + + this.title.setTitle(page.title); + this.updateNamedTag('description', page.description); + this.updateNamedTag('robots', page.robots ?? 'index,follow'); + this.updateNamedTag('author', 'Ditectrev, contact@ditectrev.com'); + + this.updatePropertyTag('og:type', 'website'); + this.updatePropertyTag('og:site_name', SITE_NAME); + this.updatePropertyTag('og:locale', 'en_US'); + this.updatePropertyTag('og:title', page.title); + this.updatePropertyTag('og:description', page.description); + this.updatePropertyTag('og:url', canonical); + this.updatePropertyTag('og:image', DEFAULT_OG_IMAGE); + this.updatePropertyTag('og:image:alt', DEFAULT_OG_IMAGE_ALT); + this.updatePropertyTag('og:image:type', 'image/png'); + this.updatePropertyTag('og:image:width', '512'); + this.updatePropertyTag('og:image:height', '512'); + + this.updateNamedTag('twitter:card', 'summary'); + this.updateNamedTag('twitter:site', '@ditectrev'); + this.updateNamedTag('twitter:creator', '@ddanielecki'); + this.updateNamedTag('twitter:title', page.title); + this.updateNamedTag('twitter:description', page.description); + this.updateNamedTag('twitter:image', DEFAULT_OG_IMAGE); + this.updateNamedTag('twitter:image:alt', DEFAULT_OG_IMAGE_ALT); + + this.setCanonical(canonical); + this.setJsonLd(this.buildJsonLd(page, canonical)); + } + + private updateNamedTag(name: string, content: string): void { + this.meta.updateTag({ name, content }); + } + + private updatePropertyTag(property: string, content: string): void { + this.meta.updateTag({ property, content }); + } + + private setCanonical(href: string): void { + let link = this.document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null; + if (!link) { + link = this.document.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.document.head.appendChild(link); + } + link.setAttribute('href', href); + } + + private setJsonLd(graph: Record[]): void { + const payload = JSON.stringify({ + '@context': 'https://schema.org', + '@graph': graph, + }); + let script = this.document.getElementById(JSON_LD_SCRIPT_ID) as HTMLScriptElement | null; + if (!script) { + script = this.document.createElement('script'); + script.id = JSON_LD_SCRIPT_ID; + script.type = 'application/ld+json'; + this.document.head.appendChild(script); + } + script.textContent = payload; + } + + private buildJsonLd( + page: PageSeo, + canonical: string + ): Record[] { + const graph: Record[] = [ + this.organizationNode(), + this.websiteNode(), + this.breadcrumbNode(page, canonical), + ]; + + if (page.jsonLdType === 'FAQPage') { + graph.push(this.faqPageNode(canonical)); + } else if (page.jsonLdType === 'Service') { + graph.push(this.serviceNode(page, canonical)); + } else if (page.jsonLdType !== 'WebSite') { + graph.push({ + '@type': page.jsonLdType, + '@id': `${canonical}#webpage`, + url: canonical, + name: page.title, + description: page.description, + isPartOf: { '@id': WEBSITE_ID }, + about: { '@id': ORGANIZATION_ID }, + inLanguage: 'en', + }); + } + + return graph; + } + + private organizationNode(): Record { + return { + '@type': ['Organization', 'ProfessionalService'], + '@id': ORGANIZATION_ID, + name: SITE_NAME, + legalName: 'IBStructure Daniel Danielecki', + url: `${SITE_ORIGIN}/`, + email: 'contact@ditectrev.com', + telephone: '+48 732 280 741', + foundingDate: '2017', + logo: `${SITE_ORIGIN}/assets/logo.svg`, + image: DEFAULT_OG_IMAGE, + description: + 'Information Technology (IT) services company focused on consulting and online education.', + address: { + '@type': 'PostalAddress', + streetAddress: 'Irysowa 18', + addressLocality: 'Jelcz-Laskowice', + postalCode: '55-220', + addressCountry: 'PL', + }, + sameAs: [...ORGANIZATION_SAME_AS], + }; + } + + private websiteNode(): Record { + return { + '@type': 'WebSite', + '@id': WEBSITE_ID, + url: `${SITE_ORIGIN}/`, + name: SITE_NAME, + inLanguage: 'en', + publisher: { '@id': ORGANIZATION_ID }, + }; + } + + private breadcrumbNode(page: PageSeo, canonical: string): Record { + const itemListElement = page.breadcrumbs + .filter((crumb) => crumb.path !== '') + .map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: crumb.name, + item: absoluteUrl(crumb.path), + })); + + return { + '@type': 'BreadcrumbList', + '@id': `${canonical}#breadcrumb`, + itemListElement, + }; + } + + private faqPageNode(canonical: string): Record { + return { + '@type': 'FAQPage', + '@id': `${canonical}#faq`, + url: canonical, + name: 'FAQ', + isPartOf: { '@id': WEBSITE_ID }, + inLanguage: 'en', + mainEntity: this.flattenFaq(FAQ_QUESTIONS).map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), + }; + } + + private serviceNode(page: PageSeo, canonical: string): Record { + return { + '@type': 'Service', + '@id': `${canonical}#service`, + name: page.serviceName ?? page.title, + description: page.description, + url: canonical, + provider: { '@id': ORGANIZATION_ID }, + serviceType: page.serviceName, + }; + } + + private flattenFaq( + nodes: FaqQuestions[], + prefix = '' + ): { question: string; answer: string }[] { + const items: { question: string; answer: string }[] = []; + for (const node of nodes) { + if (node.question && node.answer) { + items.push({ + question: prefix ? `${prefix}: ${node.question}` : node.question, + answer: this.stripHtml(node.answer), + }); + } + if (node.questions?.length) { + const nextPrefix = node.category ?? prefix; + items.push(...this.flattenFaq(node.questions, nextPrefix)); + } + } + return items; + } + + private stripHtml(value: string): string { + return value.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + } +} diff --git a/src/index.html b/src/index.html index 5ce114a..217af8b 100644 --- a/src/index.html +++ b/src/index.html @@ -1,10 +1,7 @@ - - 🎓 Ditectrev – Online Education and Information Technology (IT) - Consulting - + Ditectrev – Online Education and IT Consulting @@ -13,12 +10,12 @@ - + @@ -28,68 +25,35 @@ - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + + - - - + + + diff --git a/src/robots.txt b/src/robots.txt index 9121f7b..fe0a5d0 100644 --- a/src/robots.txt +++ b/src/robots.txt @@ -1,4 +1,6 @@ User-agent: * -Disallow: +Allow: / +Disallow: /health + Sitemap: https://ditectrev.com/sitemap.xml -Host: https://ditectrev.com \ No newline at end of file +Host: https://ditectrev.com diff --git a/src/server.ts b/src/server.ts index ad91718..977bf49 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import { join } from 'path'; import { existsSync, readFileSync } from 'fs'; import helmet from 'helmet'; import { AppServerModule } from './app/app.server.module'; +import { isIndexablePath } from './app/data/seo.data'; // CI writes `functions/.env` from GitHub secrets before `firebase deploy` (see `scripts/write-functions-env.mjs`). // Local dev: repo root `.env` (second line). Packaged `.env` is loaded first on Cloud Functions. @@ -125,6 +126,10 @@ app.get('*', (req, res) => { document: indexHtml, url: req.url, }); + if (!isIndexablePath(req.path || req.url)) { + res.status(404); + res.set('X-Robots-Tag', 'noindex, follow'); + } res.send(html); } catch (err: unknown) { const errObj = err as any; diff --git a/src/sitemap.xml b/src/sitemap.xml index 41ea5bd..7d1aaf0 100644 --- a/src/sitemap.xml +++ b/src/sitemap.xml @@ -8,79 +8,64 @@ https://ditectrev.com/ - 2021-04-10T15:36:50+00:00 1.00 https://ditectrev.com/services - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/methodology - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/about-us - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/contact - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/services/cyber-security - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/services/digital-strategy - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/services/software-development - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/faq - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/glossary - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/partnerships - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/copyrights - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/privacy-and-security - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/sitemap - 2021-04-10T15:36:50+00:00 0.80 https://ditectrev.com/terms-of-use - 2021-04-10T15:36:50+00:00 0.80 - \ No newline at end of file + diff --git a/src/styles.scss b/src/styles.scss index 440d1df..efb761a 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -261,6 +261,7 @@ mat-card { mat-card-content { margin-top: 1rem; + h2, h3 { text-align: center; } @@ -274,6 +275,16 @@ mat-card { text-align: center; } + mat-card-title h1, + mat-card-title h2 { + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; + margin: 0; + text-align: inherit; + } + // Required to put it here in order to overwrite Angular Material's styles used in a contact. .mat-form-field-subscript-wrapper { padding: 0 !important; From 682e7e8fbaeca6b0c68f132078d9f24ee9c93b77 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 09:06:19 +0000 Subject: [PATCH 2/3] fix: complete HTML stripping for FAQ JSON-LD text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop tag removal until the string is stable and drop leftover angle brackets so nested or unclosed tags cannot survive a single regex pass. Co-authored-by: ✅ Daniel Danielecki --- src/app/services/seo.service.spec.ts | 19 ++++++++++++++++++- src/app/services/seo.service.ts | 19 ++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/app/services/seo.service.spec.ts b/src/app/services/seo.service.spec.ts index a6b849d..89d18e6 100644 --- a/src/app/services/seo.service.spec.ts +++ b/src/app/services/seo.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { Meta, Title } from '@angular/platform-browser'; -import { SeoService } from './seo.service'; +import { SeoService, stripHtml } from './seo.service'; import { DEFAULT_OG_IMAGE, NOT_FOUND_SEO, @@ -96,6 +96,23 @@ describe('SeoService', () => { }); }); +describe('stripHtml', () => { + it('should keep link text and remove tags from FAQ-style markup', () => { + expect( + stripHtml( + 'Please check our Privacy & Security policy.' + ) + ).toBe('Please check our Privacy & Security policy.'); + }); + + it('should not leave nested or unclosed script tags', () => { + expect(stripHtml('<')).not.toMatch(/')).not.toMatch(/