Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SEO Support #23

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions blog-app/components/PageLayout/Meta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Ported from https://github.com/mildronize/mildronize.github.io/blob/main/src/components/SEO/SEO.jsx

import Head from 'next/head';
import urljoin from 'url-join';
import { siteMetadata } from '@thadaw.com/data';
import { IPostSerializableJSON } from '@thadaw.com/libs/content-service';
import { getUnsplashImageURL } from '@thadaw.com/libs/utility';

export interface IMetaProps {
pageTitle?: string;
postNode?: Pick<
IPostSerializableJSON,
'date' | 'description' | 'excerpt' | 'cover' | 'shortURL' | 'unsplashImgCoverId'
>;
postSEO?: boolean;
}

const getImagePath = (imageURI: string) => {
if (!imageURI) return;
if (!imageURI.match(`(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]`))
return urljoin(siteMetadata.site.siteUrl, siteMetadata.site.pathPrefix, imageURI);
return imageURI;
};

export default function Meta({ pageTitle, postNode, postSEO = false }: IMetaProps) {
let description;
let image;
let postURL;

const title = pageTitle ? pageTitle : siteMetadata.title;
const metaTitle = pageTitle ? `${pageTitle} | ${siteMetadata.title}` : siteMetadata.title;
const datePublished = postNode?.date;

if (postSEO) {
description = postNode?.description ? postNode?.description : postNode?.excerpt;
image = postNode?.cover;
postURL = urljoin(siteMetadata.site.siteUrl, siteMetadata.site.pathPrefix, postNode?.shortURL || '');
} else {
description = siteMetadata.site.siteDescription;
image = siteMetadata.site.siteLogo;
}

if (postNode?.unsplashImgCoverId) {
image = getUnsplashImageURL(postNode?.unsplashImgCoverId);
} else if (postNode?.cover) {
image = urljoin(siteMetadata.site.siteUrl, postNode?.cover);
image = getImagePath(image);
}

const authorJSONLD = {
'@type': 'Person',
name: siteMetadata.author.userName,
email: siteMetadata.author.userEmail,
address: siteMetadata.author.userLocation,
};

const logoJSONLD = {
'@type': 'ImageObject',
url: getImagePath(siteMetadata.site.siteLogo),
};

const blogURL = urljoin(siteMetadata.site.siteUrl, siteMetadata.site.pathPrefix);

interface ISchemaOrgJSONLD {
'@context': string;
'@type': string;
url?: string;
name?: string;
headline?: string;
image?: any;
author?: typeof authorJSONLD;
publisher?: any;
datePublished?: string;
alternateName?: string;
description?: string;
itemListElement?: any[];
}

const schemaOrgJSONLD: ISchemaOrgJSONLD[] = [
{
'@context': 'http://schema.org',
'@type': 'WebSite',
url: blogURL,
name: title,
alternateName: siteMetadata.site.siteTitleAlt ? siteMetadata.site.siteTitleAlt : '',
},
];
if (postSEO) {
schemaOrgJSONLD.push(
{
'@context': 'http://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
item: {
'@id': postURL,
name: title,
image,
},
},
],
},
{
'@context': 'http://schema.org',
'@type': 'BlogPosting',
url: blogURL,
name: title,
alternateName: siteMetadata.site.siteTitleAlt ? siteMetadata.site.siteTitleAlt : '',
headline: title,
image: { '@type': 'ImageObject', url: image },
author: authorJSONLD,
publisher: {
...authorJSONLD,
'@type': 'Organization',
logo: logoJSONLD,
},
datePublished: datePublished || '',
description,
}
);
}

return (
<Head>
<title>{metaTitle}</title>
{/* General tags */}
<meta name="description" content={description} />
<meta name="image" content={image} />

{/* Schema.org tags */}
<script type="application/ld+json">{JSON.stringify(schemaOrgJSONLD)}</script>

{/* OpenGraph tags */}
<meta property="og:url" content={postSEO ? postURL : blogURL} />
{postSEO ? <meta property="og:type" content="article" /> : null}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="fb:app_id" content={siteMetadata.site.siteFBAppID ? siteMetadata.site.siteFBAppID : ''} />

{/* Twitter Card tags */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content={siteMetadata.author.userTwitter ? siteMetadata.author.userTwitter : ''} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
</Head>
);
}
28 changes: 0 additions & 28 deletions blog-app/components/PageLayout/PageMeta.tsx

This file was deleted.

10 changes: 5 additions & 5 deletions blog-app/components/PageLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import React from 'react';
import Footer from './PageFooter';
import Meta, { IMetaProps } from './PageMeta';
import Footer from './Footer';
import Meta, { IMetaProps } from './Meta';
import Topbar from './Topbar';

interface ILayoutProps extends IMetaProps {
children?: React.ReactNode;
}

export default function Layout({ children, pageTitle }: ILayoutProps) {
export default function Layout(props: ILayoutProps) {
return (
<>
<Meta pageTitle={pageTitle} />
<Meta {...props} />
<Topbar />
<div className="min-h-screen">
<main>{children}</main>
<main>{props.children}</main>
</div>
<Footer />
</>
Expand Down
7 changes: 2 additions & 5 deletions blog-app/components/Post/PostBody.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import markdownStyles from './markdown-styles.module.css'
import markdownStyles from './markdown-styles.module.css';

interface IPostBodyProps {
content: string;
Expand All @@ -7,10 +7,7 @@ interface IPostBodyProps {
export default function PostBody({ content }: IPostBodyProps) {
return (
<div className="max-w-2xl mx-auto prose prose-lg prose-slate dark:prose-dark">
<div
className={markdownStyles['markdown']}
dangerouslySetInnerHTML={{ __html: content }}
/>
<div className={markdownStyles['markdown']} dangerouslySetInnerHTML={{ __html: content }} />
</div>
);
}
2 changes: 1 addition & 1 deletion blog-app/components/PostListByYear.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import DateFormatter from './DateFormatter';
import { IPostSerializableJSON } from '@thadaw.com/libs/content-service';
import { postPath } from '@thadaw.com/libs/utility';

type PostListByYear = Pick<IPostSerializableJSON, 'slug' | 'title' | 'date'>
type PostListByYear = Pick<IPostSerializableJSON, 'slug' | 'title' | 'date'>;

interface IPostListByYearProps {
posts: PostListByYear[];
Expand Down
22 changes: 22 additions & 0 deletions blog-app/data/interfaces/ISiteMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ export interface ISiteMetadata {
Default theme when web loaded
*/
theme: 'system' | 'dark' | 'light';
site: {
/** Domain of your website without pathPrefix. */
siteUrl: string;
/** Alternative site title for SEO. */
siteTitleAlt: string;
/** Prefixes all links. For cases when deployed to example.github.io/blog-next/. */
pathPrefix: string;
/** Website description used for RSS feeds/meta description tag. */
siteDescription: string;
/** Logo used for SEO and manifest. */
siteLogo: string;
/** FB Application ID for using app insights */
siteFBAppID: string;
};
/** author info for SEO */
author: {
userName: string; // Username to display in the author segment.
userEmail: string; // Email used for RSS feed's author segment
userTwitter: string;
userGithub: string;
userLocation: string; // User location to display in the author seg
};
posts: {
/** the root of content directory, it can contains various type of contents e.g.
posts, pages */
Expand Down
15 changes: 15 additions & 0 deletions blog-app/data/siteMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@
const siteMetadata = {
title: 'Thada W.',
theme: 'system',
site: {
siteUrl: 'https://v6.thadaw.com',
siteTitleAlt: "Thada W.",
pathPrefix: '/',
siteDescription: 'You can find almost stuff about me: sharing ideas, programming techniques, web technology and others.',
siteLogo: '/logos/android-chrome-512x512.png',
siteFBAppID: "487836291329244",
},
author: {
userName: "Thada Wangthammang", // Username to display in the author segment.
userEmail: "[email protected]", // Email used for RSS feed's author segment
userGithub: "mildronize",
userTwitter: "mildronize",
userLocation: "Songkhla, Thailand", // User location to display in the author segment.
},
posts: {
contentDirectory: '../contents',
postDirectory: '../contents/posts',
Expand Down
32 changes: 29 additions & 3 deletions blog-app/libs/content-service/PostData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import matter from 'gray-matter';
import fs from 'fs/promises';
import excerptHtml from 'excerpt-html';
import { getActualFilename, extractDate, extractFilenameSlug } from './pathUtility';
import { retryNewUuid, getUuidStore, generateUUID } from './Uuid';
import { getAllMarkdownPaths } from './postUtility';
Expand All @@ -25,13 +26,18 @@ export interface IPostSerializableJSON extends SerializablePostData {
export interface IFrontmatter {
title?: string;
uuid?: string;
cover?: string;
description?: string;
unsplashImgCoverId?: string;
}

export interface IField {
slug: string;
actualDate: Date | null;
path: string;
filenameSlug: string;
excerpt: string;
shortURL: string | null;
}

export default class PostData {
Expand All @@ -51,13 +57,18 @@ export default class PostData {
actualDate: date,
path: relativePath,
filenameSlug: extractFilenameSlug(filename),
excerpt: this.convertHtmlToExcerpt(content),
shortURL: this.getShortURL(),
};
}

private importFrontmatter(data: Record<string, any>) {
private importFrontmatter({ title, uuid, cover, description, unsplashImgCoverId }: Record<string, any>) {
const result: IFrontmatter = {
title: data.title,
uuid: data.uuid,
title,
uuid,
cover,
description,
unsplashImgCoverId,
};
return result;
}
Expand All @@ -75,6 +86,20 @@ export default class PostData {
return `${readableSlug}-${uuid}`;
}

private convertHtmlToExcerpt(htmlCode: string) {
// 140 chars for thai, 55 for eng
return excerptHtml(htmlCode, {
stripTags: true, // Set to false to get html code
pruneLength: 140, // Amount of characters that the excerpt should contain
pruneString: '…', // Character that will be added to the pruned string
pruneSeparator: ' ', // Separator to be used to separate words
});
}

private getShortURL() {
return this.frontmatter.uuid ? `/s/${this.frontmatter.uuid}` : null;
}

public async injectUUID() {
let uuid = '';
if (!('uuid' in this.frontmatter)) {
Expand All @@ -85,6 +110,7 @@ export default class PostData {
this.frontmatter.uuid = uuid;
await fs.writeFile(this.field.path, matter.stringify(this.content, this.frontmatter), defaultUnicode);
}
return uuid;
}

/**
Expand Down
Loading