diff --git a/angular.json b/angular.json index 03815d4..0527a4f 100644 --- a/angular.json +++ b/angular.json @@ -28,6 +28,8 @@ "src/favicon.ico", "src/ads.txt", "src/robots.txt", + "src/llms.txt", + "src/llms-full.txt", "src/sitemap.xml", "src/assets", "src/manifest.webmanifest", diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts new file mode 100644 index 0000000..5f774be --- /dev/null +++ b/e2e/seo.spec.ts @@ -0,0 +1,65 @@ +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('serves llms.txt for AI agents', async ({ request }) => { + const response = await request.get('/llms.txt'); + expect(response.ok()).toBeTruthy(); + const body = await response.text(); + expect(body).toMatch(/^# Ditectrev/m); + expect(body).toContain('https://ditectrev.com/faq'); + expect(body).toContain('llms-full.txt'); +}); + +test('serves llms-full.txt with citation Q&A', async ({ request }) => { + const response = await request.get('/llms-full.txt'); + expect(response.ok()).toBeTruthy(); + const body = await response.text(); + expect(body).toContain('What is Ditectrev?'); + expect(body).toContain('IBStructure Daniel Danielecki'); +}); + +test('XML sitemap lists llms.txt for crawler discovery', async ({ request }) => { + const response = await request.get('/sitemap.xml'); + expect(response.ok()).toBeTruthy(); + const body = await response.text(); + expect(body).toContain('https://ditectrev.com/llms.txt'); + expect(body).toContain('https://ditectrev.com/llms-full.txt'); +}); + +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..c4560d0 --- /dev/null +++ b/src/app/data/seo.data.ts @@ -0,0 +1,319 @@ +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 LLMS_TXT_URL = `${SITE_ORIGIN}/llms.txt`; +export const INDEX_ROBOTS = + 'index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1'; +export const NOINDEX_ROBOTS = 'noindex, follow'; + +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_ROBOTS, + jsonLdType: 'WebPage', + breadcrumbs: [homeCrumb, { name: 'Page not found', path: '' }], +}; + +/** Entity-defining Q&A copied from the FAQ page for homepage JSON-LD. */ +export const ENTITY_FAQS: readonly { question: string; answer: string }[] = [ + { + question: 'What is Ditectrev?', + answer: + 'We are IT company providing online education content and IT Consulting.', + }, + { + question: 'What are you doing?', + answer: + 'We provide online education supported by our blog and three categories of services, i.e. Cyber Security, Digital Strategy and Software Development. Our blog and educational content is related to these three types of services.', + }, + { + question: 'Where is the company located?', + answer: + 'We are working fully remotely from different countries and the company is registered in Poland.', + }, + { + question: 'How are you working?', + answer: + 'We are working using agile technique called Scrum. This is a process for managing projects which relies on frequent updates of a client and delivering small parts, but often during project ongoing.', + }, +]; + +export const SERVICE_OFFERINGS: Record = { + '/services/cyber-security': [ + 'Compliance Audits', + 'Cyber Research', + 'Ethical Hacking', + 'Fuzzing', + 'Post-Incident Analysis', + 'Proactive Cyber Defence', + 'Reverse Engineering', + 'Secure Programming', + 'Security Analysis', + 'Systems Security', + 'Social Engineering', + 'Source Code Audits', + ], + '/services/digital-strategy': [ + 'Business Development', + 'Competitors Analysis', + 'Crowdfunding & Crowdsourcing', + 'Culture Design', + 'Data Analysis', + 'Digital Marketing', + 'Foresight & Trends', + 'Innovation Factory', + 'Interactive Systems', + 'Lean Innovation', + "Minimum Viable Products (MVP's) & Proof of Concepts (PoF's)", + 'Product Design', + ], + '/services/software-development': [ + 'Business Integrations', + 'Cloud Solutions', + 'Code Reviews', + 'Content Management Systems', + 'Complex Services', + 'Custom Implementations', + 'Electronic Commerce (eCommerce)', + 'Mobile Development', + 'Progressive Web Applications', + 'Research & Discovery', + 'Software Quality Engineering', + 'Web Development', + ], +}; + +export const METHODOLOGY_STEPS: readonly { name: string; text: string }[] = [ + { + name: 'Business Analysis', + text: + 'Stage of listening and asking question. Learn about your project, try to understand its business needs and ask questions. Find your differentiators and unique value proposition.', + }, + { + name: 'Research', + text: + 'Stage of researching business and technical capabilities. Investigate your business needs and combine them with a technical realities. Find a golden ratio for business needs and technical realities. Provide you a feedback what we can do. Estimate range of the project in terms of budget and deadline.', + }, + { + name: 'Technical Assessment', + text: + 'Stage of evaluation technical possibilities with certain budget. Determine the budget. Analysis what kind of technologies and tools to use. Propose a solution according to your needs and budget.', + }, + { + name: 'Realization', + text: + 'Stage of execution for the project. Start with small part of the project. Finish the small part of the project. Apply your remarks. Iterate these steps for yet another parts of the project.', + }, + { + name: 'Further Cooperation', + text: + 'Stage of long term partnership. Support you with what we delivered. Further improvements.', + }, +]; + +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..4d0c8c8 --- /dev/null +++ b/src/app/services/seo.service.spec.ts @@ -0,0 +1,187 @@ +import { TestBed } from '@angular/core/testing'; +import { Meta, Title } from '@angular/platform-browser'; +import { SeoService, stripHtml } 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,max-snippet:-1,max-image-preview:large,max-video-preview:-1' + ); + 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'); + const org = parsed['@graph'].find( + (node: { '@id'?: string }) => node['@id'] === `${SITE_ORIGIN}/#organization` + ); + expect(org?.knowsAbout).toContain('Cyber Security'); + expect(org?.hasOfferCatalog?.itemListElement?.length).toBe(3); + const homePage = parsed['@graph'].find( + (node: { '@type': string; mainEntity?: unknown }) => + node['@type'] === 'WebPage' && Array.isArray(node.mainEntity) + ); + expect(homePage.mainEntity.length).toBeGreaterThan(0); + expect(org?.taxID).toBe('PL9121899240'); + expect( + document.querySelector('link[rel="describedby"]')?.getAttribute('href') + ).toBe(`${SITE_ORIGIN}/llms.txt`); + expect( + document + .querySelector('link[rel="alternate"][type="text/markdown"]') + ?.getAttribute('href') + ).toBe(`${SITE_ORIGIN}/llms.txt`); + }); + + 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`); + expect(serviceNode?.hasOfferCatalog?.itemListElement?.length).toBe(12); + }); + + it('should inject HowTo JSON-LD for the methodology page', () => { + service.updateForUrl('/methodology'); + + const script = document.getElementById('ditectrev-jsonld'); + const parsed = JSON.parse(script?.textContent ?? '{}'); + const howTo = parsed['@graph'].find( + (node: { '@type': string }) => node['@type'] === 'HowTo' + ); + expect(howTo?.name).toBe('Ditectrev project methodology'); + expect(howTo?.step?.length).toBe(5); + expect(howTo?.step[0].name).toBe('Business Analysis'); + }); + + it('should inject DefinedTermSet JSON-LD for the glossary page', () => { + service.updateForUrl('/glossary'); + + const script = document.getElementById('ditectrev-jsonld'); + const parsed = JSON.parse(script?.textContent ?? '{}'); + const termSet = parsed['@graph'].find( + (node: { '@type': string }) => node['@type'] === 'DefinedTermSet' + ); + expect(termSet?.hasDefinedTerm?.length).toBeGreaterThan(20); + expect(termSet.hasDefinedTerm[0]['@type']).toBe('DefinedTerm'); + expect(termSet.hasDefinedTerm[0].name).toBeTruthy(); + }); + + it('should inject ItemList JSON-LD for the services overview', () => { + service.updateForUrl('/services'); + + const script = document.getElementById('ditectrev-jsonld'); + const parsed = JSON.parse(script?.textContent ?? '{}'); + const list = parsed['@graph'].find( + (node: { '@type': string }) => node['@type'] === 'ItemList' + ); + expect(list?.itemListElement?.length).toBe(3); + expect(list.itemListElement[0].name).toBe('Cyber Security'); + }); +}); + +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(/