SEO in Angular met SSR - Deel I

Vandaag stel ik een service samen die mijn SEO-tags, metatags, paginatitels voor zowel Angular SSR als SPA afhandelt. (Te veel acroniemen! Server-side rendering en single-page applicatie - dat wil zeggen, client-side rendering). Dit wordt gedaan voor op inhoud gebaseerde websites , die al dan niet statisch kan zijn (zoals in een JAM-stack).

Dit is het gewenste resultaat:

De vereiste HTML-tags

De volgende tags moeten voor elke pagina worden weergegeven.

<title>Page title - site title</title>

<!-- open graph -->
<meta property="og:site_name" content="Sekrab Garage">
<meta property="og.type"      content="website">
<meta property="og:url"       content="pageUrl"/>
<meta name="description" property="og:description" content="description is optional">
<meta name="title" property="og:title" content="Page title">
<meta name="image" property="og:image" content="imageurl">


<!-- twitter related -->
<meta property="twitter:site" content="@sekrabbin">
<meta property="twitter:card" content="summary_large_image"/>
<meta preoprty="twitter:creator" content="@sekrabbin">
<meta property="twitter:image" content="imageurl">
<meta property="twitter:title" content="title">
<meta property="twitter:description" content="description">

<!-- general and for compatibility purposes -->
<meta name="author" content="Ayyash">

<!-- cononical, if you have multiple languages, point to default -->
<link rel="canonical" href="https://elmota.com"/>

<!-- alternate links, languages -->
<link rel="alternate" hreflang="ar-jo" href="ar link">
<meta property="og:locale" content="en_GB" />

We zullen een service maken, geleverd in de root, geïnjecteerd in de root-component. Dan hebben we een manier nodig om tags voor verschillende routes bij te werken. Dus uiteindelijk hebben we een "Tags toevoegen . nodig " en "Tags bijwerken " openbare methoden. Met behulp van de twee services van Angular:Meta en Title.

@Injectable({
    providedIn: 'root'
})
export class SeoService {

  // inject title and meta from @angular/platform-browser
  constructor(
    private title: Title,
    private meta: Meta
    ) {
    // in constructor, need to add fixed tags only
  }

  AddTags() {
    // TODO: implement
  }

  UpdateTags() {
    // TODO: implement
  }
}

We hebben ook het DOCUMENT-injectietoken nodig om de link toe te voegen. De service ziet er nu zo uit

@Injectable({
  providedIn: 'root',
})
export class SeoService {
  constructor(
    private title: Title,
    private meta: Meta,
    @Inject(DOCUMENT) private doc: Document
  ) {}

  AddTags() {
    const tags = [
      { property: 'og:site_name', content: 'Sekrab Garage' },
      { property: 'og.type', content: 'website' },
      { property: 'og:url', content: 'pageUrl' },
      { property: 'twitter:site', content: '@sekrabbin' },
      { property: 'twitter:card', content: 'summary_large_image' },
      { property: 'twitter:creator', content: '@sekrabbin' },
      { property: 'twitter:image', content: 'imageurl' },
      { property: 'twitter:title', content: '[title]' },
      { property: 'twitter:description', content: '[description]' },
      { property: 'og:locale', content: 'en_GB' },
      {
        name: 'description',
        property: 'og:description',
        content: '[description]',
      },
      { name: 'title', property: 'og:title', content: '[title]' },
      { name: 'image', property: 'og:image', content: 'imageurl' },
      { name: 'author', content: 'Ayyash' },
    ];

    // add tags
    this.meta.addTags(tags);

    // add title
    this.title.setTitle('[Title] - Sekrab Garage');

    // add canonical and alternate links
    this.createCanonicalLink();
    this.createAlternateLink();
  }
  private createAlternateLink() {
    // append alternate link to body, TODO: url and hreflang 
    const _link = this.doc.createElement('link');
    _link.setAttribute('rel', 'alternate');
    _link.setAttribute('hreflang', 'en');
    _link.setAttribute('href', '[url]');
    this.doc.head.appendChild(_link);
  }

  private createCanonicalLink() {
    // append canonical to body, TODO: url
    const _canonicalLink = this.doc.createElement('link');
    _canonicalLink.setAttribute('rel', 'canonical');
    _canonicalLink.setAttribute('href', '[url]');
    this.doc.head.appendChild(_canonicalLink);
  }

  UpdateTags() {
    // TOOD: find out what we need to update
  }
}

Niet alle metatags hoeven te worden bijgewerkt, dus degenen die niet worden bijgewerkt, zullen we in de serviceconstructor injecteren. Maar voordat ik dat doe, wil ik de tags buiten mijn service plaatsen , zullen later nadenken over waar ze moeten worden geplaatst. Voor nu wil ik twee arrays maken, één voor fixedTags:

// outside service class
const tags =  [
    { property: "og:url", content: "pageUrl" },
    { property: "twitter:image", content: "imageurl" },
    { property: "twitter:title", content: "[title]" },
    { property: "twitter:description", content: "[description]" },
    { name: "description", property: "og:description", content: "[description]" },
    { name: "title", property: "og:title", content: "[title]" },
    { name: "image", property: "og:image", content: "imageurl" }
 ]

const fixedTags = [
    { property: "og:site_name", content: "Sekrab Garage", dataAttr:'ayyash' },
    { property: "og.type", content: "website" },
    { property: "twitter:site", content: "@sekrabbin" },
    { property: "twitter:card", content: "summary_large_image" },
    { property: "twitter:creator", content: "@sekrabbin" },
    { property: "og:locale", content: "en_GB" },
    { name: "author", content: "Ayyash" }
]

Het andere uiteinde

De simplistische manier om SEO te implementeren, gaat als volgt:in elke route, na het ophalen van details van de server, update titel, beschrijving, afbeelding... etc.

@Component({
    templateUrl: './view.html',
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectViewComponent implements OnInit {

    project$: Observable<any>;

    constructor(private route: ActivatedRoute, 
        private projectService: ProjectService,
        private seoService: SeoService) {
    }
    ngOnInit(): void {
        this.project$ = this.route.paramMap.pipe(
            switchMap(params => {
                // get project from service by params
                return this.projectService.GetProject(params.get('id'));
            }),
            tap(project => {
                // I want to get to this
                this.seoService.UpdateTags({
                  // some pages don't have it from server
                  title: project.title,
                  // optional
                  description: project.description, 
                  // out of context in some pages
                  url: this.route.snapshot.url, 
                  // may not be known
                  image: project.image 
                });

            })
        )
    }   
}

Het doorgeven van parameters is voor mij niet voldoende:sommige pagina's hebben geen afbeelding, zoals een lijstpagina, andere hebben misschien een afbeelding of een titel die niet door de server wordt gevoed. Sommige pagina's kunnen pagineringsinformatie bevatten. De url is een stukje eigen werk, aangezien niet alle componenten van route afhankelijk zijn. Wat ik wil, is een centrale plek om voor alle bits te zorgen , zoiets als dit

this.seoService.setProject(project)

 ngOnInit(): void {
    this.project$ = this.route.paramMap.pipe(
        switchMap(params => {
            // get project from service by params
             return this.projectService.GetProject(params.get('id'));
        }),
        // map or tap
        tap(project => {
          // do magic away from here
          if (project) {
            this.seoService.setProject(project);
          }
       })
    );
}

De magie zit in de SEO-service:

setProject(project: IProject) {
    // set title
    const title = `${project.title} - Sekrab Garage`;
    this.title.setTitle(title);
    this.meta.updateTag({ property: 'og:title', content: title });
    this.meta.updateTag({ property: 'twitter:title', content: title});

    // set url, from doc injection token (next week we'll cover url in details)
    this.meta.updateTag({ property: 'og:url', content: this.doc.URL });

    // set description
    this.meta.updateTag({ name: 'description', property: 'og:description', content: project.description });

    // set image
    this.meta.updateTag({ name: 'image', property: 'og:image', content: project.image });
    this.meta.updateTag({ property: "twitter:image", content:  project.image});
  }

Dit zal een gebruikspatroon zijn, dus laat me aparte methoden maken voor het instellen van de metatags.

setProject(project: any) {
  // set title
  this.setTitle(project.title);

  // set url
  this.setUrl();

  // set description
  this.setDescription(project.description);

  // set image
  this.setImage(project.image);
}

private setTitle(title: string) {
    const _title = `${ title } - Sekrab Garage`;

    this.title.setTitle(_title);
    this.meta.updateTag({ name: 'title', property: 'og:title', content: _title });
    this.meta.updateTag({ property: 'twitter:title', content: _title });

}
private setDescription(description: string) {
    this.meta.updateTag({ name: 'description', property: 'og:description', content: description });
}
private setImage(imageUrl: string) {
    this.meta.updateTag({ name: 'image', property: 'og:image', content: imageUrl });
    this.meta.updateTag({ property: "twitter:image", content: imageUrl });
}
private setUrl() {
  // next week we'll dive into other links
    this.meta.updateTag({ property: 'og:url', content: this.doc.URL });

}

Pagina met vermeldingen

Wat betreft de projectenlijst, vandaag is het vrij eenvoudig, maar in de toekomst zal dit een pagina met zoekresultaten zijn. Het benodigde resultaat is een beetje slimmer dan een simpele "lijst met projecten".** Bijvoorbeeld bij het opzoeken van een restaurant:**

Title: 345 Restaurants, Chinese Food in San Francisco

Description: Found 345 Restaurants of Chinese food, with delivery, in San Francisco

De afbeelding is ook onbekend, we kunnen ofwel terugvallen op de standaard, of een categoriespecifieke afbeelding opzoeken. Ik wil klaar zijn voor zoekresultaten:

setSearchResults(total: number, category?: string) {
    // Title: 34 projects in Turtles.
    // Desc: Found 34 projects categorized under Turtles.
    // TODO: localization and transalation...
    this.setTitle(`${ total } projects in ${ category }`);
    this.setDescription(`Found ${ total } projects categorized under ${ category }`);
    this.setUrl();
    this.setImage(); // rewrite service to allow null
}

 private setImage(imageUrl?: string) {
        // prepare image, either passed or defaultImage
        // TODO: move defaultImage to config
        const _imageUrl = imageUrl || defaultImage;

        this.meta.updateTag({ name: 'image', property: 'og:image', content: _imageUrl });
        this.meta.updateTag({ property: 'twitter:image', content: _imageUrl });

    }

Titel structureren

Titel bestaat uit de volgende delen:

project title, extra info - Site name

Het eerste deel wordt aangestuurd door de server. Maar sommige pagina's kunnen statisch zijn , zoals "contacteer ons", "Registreer" of "Pagina niet gevonden." Het tweede deel is erg contextueel, in sommige apps, zoals een restaurantzoeker-app, is het beter SEO om op deze manier extra informatie over het restaurant toe te voegen

Turtle Restaurant, 5 stars in San Francisco - Site name

In ons eenvoudige project is de categorie de enige extra informatie:

 setProject(project: IProject) {
    // set title
    this.setTitle(`${project.title}, in ${project.category}`);

    // ... the rest
 }

Statische paginatitels met routegegevens

In plaats van de SEO-setter in elk onderdeel aan te roepen, ga ik voor statische pagina's de root gebruiken app.component constructor, en de routes zich. Tonen, niet vertellen:

In een routedefinitie

 {
    path: 'contact',
    component: ProjectContactComponent,
    data: {
      // add an optional parameter. TODO: translation
      title: 'Contact us about a project',
    },
  }

In root app.component , bekijk gebeurteniswijzigingen en filter NavigationEnd eruit evenementen

export class AppComponent {
  constructor(
    private router: Router,
    private activatedRoute: ActivatedRoute,
    private seoService: SeoService
  ) {
    this.router.events
      .pipe(filter((e) => e instanceof NavigationEnd))
      .subscribe((event) => {
        // get the route, right from the root child
        // this allows a title to be set at any level
        // but for this to work, the routing module should be set with paramsInheritanceStrategy=always
        let route = this.activatedRoute.snapshot;
        while (route.firstChild) {
          route = route.firstChild;
        }
        // create a function with a fallback title
        this.seoService.setPageTitle(route.data?.title);
      });
  }
}

In SeoService:

 setPageTitle(title: string) {
    // set to title if found, else fall back to default
    this.setTitle(title || 'Welcome to Turtles and Lizards');
  }

Om de titel op elk routeringsniveau op te halen, moeten we de rootroutingmodule aanpassen om op elk niveau te lezen (paramsInheritanceStrategy), is de titelwaarde die wordt opgehaald het diepste kind in de beoogde route , die een titelwaarde heeft, ongeacht hoe ondiep het is (het kan de wortel zijn).

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      // this is important if you are going to use "data:title" from any level
      paramsInheritanceStrategy: 'always',
    }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Dit lost ook een ander probleem op. Dat is standaard voor alle routes zorgen . Als we geen standaard fallback doen, kunnen de titels te lang blijven hangen in meerdere navigaties.

Kanttekening over volgorde van gebeurtenissen

Aangezien we de titel van meerdere locaties instellen, moet u in de gaten houden wat het laatst voorkomt, is dit wat u van plan was? Omdat feature-componenten meestal API-ophaalacties inhouden, zijn ze gegarandeerd de laatste, maar als u een constante paginatitel instelt, weet dan wat er eerst gebeurt, is het NavigationEnd, componet constructor of OnInit ?

Refactoren

Tijd om de kleine stukjes bij elkaar te brengen op één plek. We moeten 'vaste tags', 'defaults' en constante strings naar een mooiere plek verplaatsen.

Kanttekening:lokalisatie en vertaling

Ik gebruik een resourceklasse om mijn strings klaar te houden voor vertaling, maar u gebruikt waarschijnlijk het i18n-pakket van Angular , en ik vergeef het je, je zou alle strings moeten lokaliseren met dat pakket.

// Config.ts
export const Config = {
  Seo: {
        tags: [
            { property: 'og:site_name', content: 'Sekrab Garage' },
            { property: 'og.type', content: 'website' },
            { property: 'twitter:site', content: '@sekrabbin' },
            { property: 'twitter:card', content: 'summary_large_image' },
            { property: 'twitter:creator', content: '@sekrabbin' },
            { property: 'og:locale', content: 'en_GB' },
            { name: 'author', content: 'Ayyash' }
        ],
        defaultImage: 'http://garage.sekrab.com/assets/images/sekrab0813.jpg'
    }
}
// in SEO service, use Config.Seo.tags and Config.Seo.defaultImage

Zet de strings samen in een bronnenbestand, vergeet niet om later te vertalen. Het eindresultaat zou er als volgt uit moeten zien:

this.setTitle(SomeRes[title] || SomeRes.DEFAULT_PAGE_TITLE);

En voor opgemaakte titels, een manier om eenvoudige tekenreeksen te vervangen door werkelijke waarden, zoals deze:

this.setTitle(SomeRes.PROJECT_TITLE.replace('$0',project.title).replace('$1',project.description));

Dus eerst de strings, en laten we ze groeperen zodat we ze sneller kunnen vinden :

// A resources.ts file, need to be localized
export const RES = {
  SITE_NAME: 'Sekrab Garage',
  DEFAULT_PAGE_TITLE: 'Welcome to Turtles and Lizards',
  // group static titles together
  PAGE_TITLES: {
    NOT_FOUND: 'Page no longer exists',
    ERROR: 'Oh oh! Something went wrong.',
    PROJECT_CONTACT: 'Contact us about a project',
    HOME: 'Homepage',
  },
  // group other formatted strings together
  SEO_CONTENT: {
    PROJECT_TITLE: '$0, in $1',
    PROJECT_RESULTS_TITLE: '$0 projects in $1',
    PROJECT_RESULTS_DESC: 'Found $0 projects categorized under $1',
  }
};

De routegegevens bevatten nu "sleutel" in plaats van de exacte titel:

 // the project route
 {
    path: 'contact',
    component: ProjectContactComponent,
    data: {
      title: 'PROJECT_CONTACT', // this is a key
    },
  },

En nog iets waar we gebruik van kunnen maken, JavaScript Replace functie:

// take a string with $0 $1 ... etc, and replace with arguments passed
export const toFormat = (s:string, ...args: any) => {
    const regExp = /\$(\d+)/gi;
    // match $1 $2 ...
    return s.replace(regExp, (match, index) => {
        return args[index] ? args[index] : match;
    });
}

Nu terug naar onze SEO-service

// the changes in the SEOService are:

  private setTitle(title: string) {
    // change this: 
    // const _title = `${title} - Sekrab Garage`;
    const _title = `${ title } - ${RES.SITE_NAME}`;

    // ... rest
  }

  setPageTitle(title: string) {
    // this
    // this.setTitle(title || 'Welcome to Turtles and Lizards');
    this.setTitle(RES.PAGE_TITLES[title] || RES.DEFAULT_PAGE_TITLE);
  }

  setProject(project: any) {
    // this
    // this.setTitle(`${project.title}, in ${project.category}`);
    this.setTitle(
      toFormat(RES.SEO_CONTENT.PROJECT_TITLE, project.title, project.category)
    );

    // ...rest
  }

  setSearchResults(total: number, category?: string) {
   // these
    // this.setTitle(`${total} projects in ${category}`);
    // this.setDescription(
    //   `Found ${total} projects categorized under ${category}`
    // );
    this.setTitle(
      toFormat(RES.SEO_CONTENT.PROJECT_RESULTS_TITLE, total, category)
    );
    this.setDescription(
      toFormat(RES.SEO_CONTENT.PROJECT_RESULTS_DESC, total, category)
    );
    // ... rest
  }

Om te vertalen, raken we nu één bestand aan. Het toevoegen van een nieuwe functie brengt een nieuwe methode met zich mee, om titel en beschrijving en eventueel afbeelding aan te passen.

Volgende...

Links in metatags zijn de document-URL, canonieke links en alternatieve links. Volgende week duiken we er in. Bedankt voor het afstemmen. Laat het me weten in de reacties als je vragen hebt.

BRONNEN

  • Metatags die Google begrijpt
  • Metatag-generator
  • Open grafiekprotocol