Tutorial

Build and deploy a blog with Fyord, Contentful, and Firebase

Scaffold App

Let's get started by installing the fyord-cli and using it spin up a new project.

Ensure you have node/npm and git installed before continuing.

After running the given commands, checkout the running site at http://localhost:4200.

Then take a moment to read the README; it's short don't worry.

npm i -g fyord-cli
fyord new fyord-tutorial
cd fyord-tutorial
npm i
npm start

Contentful Setup

Now that our new Fyord app is up and running, Let's go ahead and take a moment to get a Contentful space ready for us when we need it. You can skip this step if you don't want to hook into live data in this tutorial.

  1. Head over to contentful.com and click "Get Started."
  2. Choose the "Start Building" option with the "Sign Up For Free" button. Not trying to pay money for anything here ;)
  3. Follow through the setup screens until you get to your home screen.
  4. Create a new empty space, and then delete any starter or example spaces the setup process may have added for you.
  5. Create an api key through Settings > API keys. We'll be using this key's Space ID and Content Delivery API - access token soon.
  6. Create a content model called "Post" and give it these fields with the specified types:

    • title | short text
    • slug | short text
    • description | short text
    • body | rich text
    • image | media
    • date | date & time
  7. Add at least one post for us to use, but the more the merrier.

Scaffold Contentful Service

Use the given command(s) to install the Contentful sdk and scaffold a new singleton service.

You could also scaffold this service anywhere you like, the given snippet is just a convenience.

npm i contentful @contentful/rich-text-types
mkdir src/services && touch src/services/module.ts && cd src/services
fyord generate singleton contentful

Add a Type to Represent Posts

Soon we'll be pulling in posts from Contentful. Let's make working with that data easier by creating a type we can use.

Add the given snippet as a file wherever you like (ex. src/contentTypes/iPost.ts):

import { Document } from'@contentful/rich-text-types';
import { Asset } from'contentful';

exportinterface IPost {
title: string;
description: string;
slug: string;
date: string;
body: Document;
image?: Asset;
}

Implement Contentful Service

In this step we'll wire up the service we created to make use of the sdk we installed.

If you skipped the contentful step earlier, consider implementing a similar service that returns some dummy data in the same shape as the Post we'll be describing.

Update the src/services/contentful/contentful.ts with the given snippet.

*Note that you will need to replaces the "SPACE_ID" and "ACCESS_TOKEN" placeholders with the values from the api key you created earlier.

import * as sdk from'contentful';

exportinterface IContentful {
GetEntries<T>(query: Record<string, string>): Promise<sdk.EntryCollection<T>>;
}

exportclassContentfulimplementsIContentful{
privatestatic instance: IContentful | null = null;
publicstatic Instance(): IContentful { returnthis.instance || (this.instance = new Contentful()); }
publicstatic Destroy = () => Contentful.instance = null;

public client: sdk.ContentfulClientApi;
private entryCache = newMap<string, sdk.EntryCollection<any>>();

privateconstructor() {
this.client = sdk.createClient({
space: 'SPACE_ID',
accessToken: 'ACCESS_TOKEN'
});
}

publicasync GetEntries<T>(query: Record<string, string>): Promise<sdk.EntryCollection<T>> {
const cachedEntries = this.entryCache.get(JSON.stringify(query));

if (cachedEntries) {
return cachedEntries;
} else {
const entries = awaitthis.client.getEntries<T>(query);
this.entryCache.set(JSON.stringify(query), entries);
return entries;
}
}
}

Update Content Security Policy (CSP)

The src/index.html contains a CSP which will block content from unapproved sources.

It's a great security practice to use a CSP, and newly scaffolded Fyord projects include them. Learn more about them here.

Update the line starting with "connect-src" in your index.html with the given snippet, replacing "SPACE_ID" with your space id:

connect-src 'self' https://cdn.contentful.com/spaces/SPACE_ID/;

Scaffold the Home/List page

We're going to need a homepage that lists out our posts.

Let's delete the existing welcome page and use the cli to scaffold a new page for us.

rm -rf src/pages/welcome
cd src/pages
fyord generate page home

Update the Home/List page

Now, we're going to want to update the title, route, and template of the new page.

Update the contents of home.tsx with the following to accomplish this:

import { Page, ParseJsx, Route } from'fyord';
import { PostCard } from'../../components/module';
import { IPost } from'../../core/contentTypes/iPost';
import { Contentful } from'../../core/services/module';
import styles from'./home.module.css';

exportclassHomeextendsPage{
Title = 'Home';
Route = async (route: Route) => route.path === '/';

Template = async () => {
const entries = await Contentful.Instance().GetEntries<IPost>({
content_type: 'post'
});

return <div class={styles.container}>
<h1>My Fyord Blog</h1>

<ul>
{entries.items.map(e => <li>
<a href={`/posts/${e.fields.slug}`}>{e.fields.title} | {new Date(e.fields.date).toLocaleDateString()}</a>
</li>)}
</ul>
</div>;
}
}

Add a post page

Use the CLI to scaffold a new 'post' page.

This time we'll use the aliased version of the cli's generate command (g = generate, p = page).

cd src/pages
fyord g p post

Implement the post page

Now let's get this page displaying our post details.

This page's route function will be quite a bit different. Consider that this page will have a different title depending on the post data. Also consider how 404/not found functionality should work.

Route resolution is very flexible in the Fyord framework. We're not just talking pattern matching here. You'll notice in our implementation for this page's route, we determine if the pattern matches, but then also determine if we have the data to support rendering the page; if not, we let our 404 page catch it. This also allows us to intuitively set the dynamic page title based on the dynamic content.

Go ahead and update the post.tsx with the following:

import { Page, ParseJsx, RawHtml, Route } from'fyord';
import { IPost } from'../../core/contentTypes/post';
import { Contentful } from'../../core/services/module';
import { documentToHtmlString } from'@contentful/rich-text-html-renderer';
import styles from'./post.module.css';

exportclassPostextendsPage{
private post!: IPost;

Route = async (route: Route) => {
let routeMatch = false;

if (route.path.startsWith('/posts/') && route.routeParams.length === 2) { /* check the pattern */
const slug = route?.routeParams[1];
const postsQueryResults = await Contentful.Instance().GetEntries<IPost>({
content_type: 'post',
'fields.slug': slug || ''
});

if (postsQueryResults.items.length >= 1) { /* check if we have the data */
this.post = postsQueryResults.items[0].fields;
this.Title = this.post.title;
routeMatch = true;
}
}

return routeMatch;
};

Template = async () => {
return <div class={styles.container}>
<article>
<h1>{this.post.title}</h1>
{this.post.image && <div class={styles.imageWrapper}>
<img src={`https:${this.post.image.fields.file.url}`} alt={this.post.image.fields.description} />
</div>}
<p>{new Date(this.post.date).toLocaleDateString()}</p>

{await new RawHtml(documentToHtmlString(this.post.body)).Render()}
</article>
</div>;
}
}

Update the layout

At this point, we've got functioning list and detail pages for our posts.

Let's take moment to update the layout with a header containing a home link and a basic footer.

One thing to note about Fyord routing, is that themain element is what is updated during routing, so you can think of it as your router "outlet."

Open src/layout.tsx and update it with the following:

import { Fragment, ParseJsx } from'fyord';

exportconst defaultLayout = async () => <>
<header>
<a href="/">Home</a>
</header>

<main></main>

<footer>
<hr />
<p>My Epic Blog Circa {new Date().getFullYear()}</p>
</footer>
</>;

Update base styles

Let's update the base styles at src/styles/base.css. These global styles apply to every component.

@import'./normalize.css';
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@400&display=swap');

* {
box-sizing: border-box;
}

body {
font-family: Rubik, Arial, Helvetica, sans-serif;
margin: 20px;
}

headerul {
display: flex;
}

headerulli {
margin-right: 20px;
}

main {
min-height: calc(80vh);
}

ul {
list-style: none;
padding: 0;
}

Update post styles

And another dash of styles on the post page - src/pages/post/post.module.css.
.container {
display: block;
}

.imageWrapper {
width: 100%;
max-height: 60vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: flex-start;
}

.imageWrapperimg {
height: 100%;
width: 100%;
}

Create post card component

We can do better than just having links to our posts. Listing them as "cards" would give us more options and feel nicer.

Let's create a component for encapsulating a post card.

cd src/components
fyord generate component postCard

Implement post card component

Update postCard.tsx with the following:
import { Component, ParseJsx } from'fyord';
import { IPost } from'../../core/contentTypes/iPost';
import styles from'./postCard.module.css';

exportclassPostCardextendsComponent{
constructor(private post: IPost) {
super();
}

Template = async () => <a class={styles.container} href={`/posts/${this.post.slug}`}>
<h2>{this.post.title}</h2>
<p>{new Date(this.post.date).toLocaleDateString()}</p>
<p>{this.post.description}</p>
</a>;
}

Add styles to post card component

Spruce it up a bit by adding the following to postCard.module.css:
.container {
display: block;
border: 1px solid black;
margin-bottom: 40px;
text-decoration: none;
color: black;
}

.container:visited {
color: black;
};

.containerh2 {
background-color: black;
color: white;
margin: 0;
padding: 10px;
}

.containerp {
margin: 0;
padding: 10px;
}

Use post card component in home page

Now that we have the post card component implemented, let's use it in our list page.

Update your list in src/pages/home/home.tsx with the list shown in the snippet. Notice how the Promise.all syntax allows us to use Array.map asynchronously.

<ul>
{awaitPromise.all(entries.items.map(async e => <li>
{await new PostCard(e.fields).Render()}
</li>))}
</ul>

Eventually we'll have so many blog posts that it might be difficult to find certain ones.

Let's go ahead and make a search page that allows users to filter our blogs based on a search term they enter.

Use the cli to scaffold the new page by entering:

cd src/pages
fyord g p search

Update search.tsx from the page you just scaffolded with the given code.

Our search page will have the following features:

  • Input that actively filters posts as the user types
  • Results presented using our post card component
  • Query parameter support allowing linking to search results - /search?query=post

The searchTerm property decorated with @State is the bit of magic that triggers our component to re-render and display new results as the user is typing. Checkout the State Decorators docs for more info.

This page is also a good example of event binding in fyord. Notice the form and input have onsubmit and oninput bound respectively. Adding event listeners in fyord is a simple as prefixing "on" in front of any valid dom event.

import { Page, ParseJsx, Fragment, Route, State, Reference } from'fyord';
import { Queryable } from'tsbase/Collections/Queryable';
import { IPost } from'../../core/contentTypes/iPost';
import { Contentful } from'../../core/services/module';
import { PostCard } from'../../components/module';
import styles from'./search.module.css';

exportclassSearchextendsPage{
Title = 'Search';
@State searchTerm = '';
private posts!: Array<IPost>;
@Referenceprivate searchInput!: HTMLInputElement;
privategetsearchResults(): Array<IPost> {
return Queryable.From(this.posts).Search(this.searchTerm).Item;
}

private onSubmit = (e: Event | null): void => {
e?.preventDefault();
this.App.Router.RouteTo(`/search?query=${this.searchTerm}`);
}

Route = async (route: Route) => {
const match = route.path === '/search';

if (match) {
this.searchTerm = route.queryParams.get('query') || '';

const entries = await Contentful.Instance().GetEntries<IPost>({
content_type: 'post'
});
this.posts = entries.items.map(e => e.fields);
}

return match;
}


Template = async (route?: Route) => {
return <div class={styles.container}>
<h1>Search My Blog</h1>

<form onsubmit={this.onSubmit}>
<input ref={this.searchInput} type="search"
placeholder="search keyword"
oninput={() => this.searchTerm = this.searchInput.value}
value={route?.queryParams.get('query') || ''}>
</input>
</form>

<section>
{this.searchTerm.trim().length >= 3 ?
<>
{this.searchResults.length >= 1 ?
<ul>
{await Promise.all(this.searchResults.map(async r => <li>
{await new PostCard(r).Render()}
</li>))}
</ul> :
<p>No results for "{this.searchTerm}"</p>}
</> :
<p>Start typing to search...</p>}
</section>
</div>;
}
}

Fix the tests

Up to this point we've ignored the spec files that have been scaffolded alongside our pages, components, etc.

Let's take a few minutes to fix the tests we already have and see where that puts us coverage wise.

Run npm run test-once | npm run test in your terminal to run the test suite.

Now, go forth and correct the errors in the tests. Most should be relatively easy to correct. If you get stumped feel free to use the below references to cheat ;)

  • home
    import { RenderModes, Route, TestHelpers, Asap } from 'fyord';import { Home } from './home';describe('Home', () => {  let classUnderTest: Home;  const pageMocks = TestHelpers.GetComponentMocks();  beforeEach(() => {    classUnderTest = new Home(      pageMocks.mockSeoService.Object,      pageMocks.mockApp.Object);  });  it('should construct', () => {    expect(classUnderTest).toBeDefined();  });  it('should have the correct render mode', () => {    expect(classUnderTest.RenderMode = RenderModes.Hybrid);  });  it('should return true for routes that match', async () => {    const route = { path: '/' } as Route;    expect(await classUnderTest.Route(route)).toBeTruthy();  });  it('should return false for routes that do not match', async () => {    const route = { path: '/not-found' } as Route;    expect(await classUnderTest.Route(route)).toBeFalsy();  });  it('should render template', async () => {    expect(await classUnderTest.Template()).toBeDefined();  });  it('should have appropriate behavior', async () => {    document.body.innerHTML = await classUnderTest.Render();    Asap(() => {      // fire any attached events    });    const behaviorExpectationsMet = await TestHelpers.TimeLapsedCondition(() => {      return true; // assertions proving expected behavior was met    });    expect(behaviorExpectationsMet).toBeTruthy();  });});
  • post
    import { RenderModes, Route, TestHelpers, Asap } from 'fyord';import { Mock } from 'tsmockit';import { IContentful } from '../../core/services/module';import { Post } from './post';import { IPost } from '../../core/contentTypes/iPost';describe('Post', () => {  let classUnderTest: Post;  const pageMocks = TestHelpers.GetComponentMocks();  const mockContentful = new Mock();  const fakeEntry = {    title: 'test'  } as IPost;  beforeEach(() => {    mockContentful.Setup(c => c.GetEntries({}), { items: [ { fields: fakeEntry }] });    classUnderTest = new Post(      mockContentful.Object,      pageMocks.mockSeoService.Object,      pageMocks.mockApp.Object);  });  it('should construct', () => {    expect(classUnderTest).toBeDefined();  });  it('should have the correct render mode', () => {    expect(classUnderTest.RenderMode = RenderModes.Hybrid);  });  it('should return true for routes that match', async () => {    const route = { path: '/posts/test', routeParams: ['post', 'test'] } as Route;    expect(await classUnderTest.Route(route)).toBeTruthy();  });  it('should return false for routes that do not match', async () => {    const route = { path: '/not-found' } as Route;    expect(await classUnderTest.Route(route)).toBeFalsy();  });  it('should render template', async () => {    classUnderTest['post'] = fakeEntry;    expect(await classUnderTest.Template()).toBeDefined();  });  it('should have appropriate behavior', async () => {    classUnderTest['post'] = fakeEntry;    document.body.innerHTML = await classUnderTest.Render();    Asap(() => {      // fire any attached events    });    const behaviorExpectationsMet = await TestHelpers.TimeLapsedCondition(() => {      return true; // assertions proving expected behavior was met    });    expect(behaviorExpectationsMet).toBeTruthy();  });});
  • search
    import { RenderModes, Route, TestHelpers, Asap } from 'fyord';import { Search } from './search';describe('Search', () => {  let classUnderTest: Search;  const pageMocks = TestHelpers.GetComponentMocks();  beforeEach(() => {    classUnderTest = new Search(      pageMocks.mockSeoService.Object,      pageMocks.mockApp.Object);  });  it('should construct', () => {    expect(classUnderTest).toBeDefined();  });  it('should have the correct render mode', () => {    expect(classUnderTest.RenderMode = RenderModes.Hybrid);  });  it('should return true for routes that match', async () => {    const route = { path: '/search', queryParams: new Map() } as Route;    expect(await classUnderTest.Route(route)).toBeTruthy();  });  it('should return false for routes that do not match', async () => {    const route = { path: '/not-found' } as Route;    expect(await classUnderTest.Route(route)).toBeFalsy();  });  it('should render template', async () => {    expect(await classUnderTest.Template()).toBeDefined();  });  it('should have appropriate behavior', async () => {    document.body.innerHTML = await classUnderTest.Render();    Asap(() => {      // fire any attached events    });    const behaviorExpectationsMet = await TestHelpers.TimeLapsedCondition(() => {      return true; // assertions proving expected behavior was met    });    expect(behaviorExpectationsMet).toBeTruthy();  });});

Once you get em all passing, open the test coverage report at coverage/lcov-report/index.html

Not too bad for just keeping the tests the cli gave you passing right?

If you feel up to it, see if you can close some of the coverage gaps. For what it's worth, the Fyord framework has 100% statement and branch coverage and that bar is definitely within reach for any Fyord app. But, we'll dive into that subject in more detail in another tutorial.

Add a pipeline

This project definitely needs a pipeline. Let's scaffold one with the cli. Yeah, the cli is pretty handy isn't it.

Use the given command to scaffold a github action that will run on all pull requests and merges to master.

Exchange 'github' for 'azure' if you'd rather use Azure Pipelines.

After the pipeline is generated, push that bad boy and watch it run. Also checkout the public artifact. This is what we'll want to deploy.

fyord g pipeline github master

Add a firebase project

Speaking of deployment, let's go ahead and get a firebase project setup to deploy to.

  1. Head over to https://console.firebase.google.com and setup an account
  2. Once you get past the intro spiel, add a new project by clicking the "add project" button
  3. Name it whatever you like, click continue, and feel free to uncheck any add ons related to analytics they try to push on you
  4. That's all for now. Firebase has a bunch of cool features, and in the next step(s) we'll be taking advantage of the hosting

Firebase init in our project

The given commands will hook you up with the firebase cli globally as well as in your local project

We'll need it in the local project in a later step.

It'll also have you login, which will let the firebase cli know how to find that project we made.

After logging in, just follow the steps in the init command to select the following:

  • Hosting
  • Use an existing project
  • {your project name}
  • press enter for default "public" directory
  • Enter "n" to not configure as a single page app (since we'll be pre-rendering)
  • Enter "n" for automatic builds in github, we'll just amend our existing pipeline
  • Then, once again enter "n" to not overwrite any index.html file that may be in our public directory
npm i -g firebase-tools
npm i --save-dev firebase-tools
firebase login
firebase init

Update firebase.json

Update your firebase.json file to the given value
{
"hosting": {
"public": "public",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"cleanUrls": true
}
}

Update package scripts

We need to make a slight modification to our build to generate a 404.html for firebase hosting. Add/update the given commands in package.json.
"build": "tsc && esrun --tsconfig=./esbuild/build.tsconfig.json ./esbuild/build.ts production && npm run addNotFoundPage",
"addNotFoundPage": "cp public/index.html public/404.html",
"deploy": "firebase deploy --token $FIREBASE_TOKEN"

Get CI deploy token

In order to give GitHub access to deploy our site to firebase, it'll need a deploy token

Execute the given command to generate one. Sign in if/when a browser window pops up, then copy the token that outputs to the terminal.

Next go to your GitHub project > settings > secrets > new repository secret. Use the name "FIREBASE_TOKEN" and paste the token in the value space.

firebase login:ci

Update pipeline with deploy step

Now, just update the pipeline we created earlier by adding the given step to the bottom (.github/workflows/ci.yml). This step will trigger a deploy on merges to your trunk branch (master).
- name: Deploy
if: ${{ github.event_name != 'pull_request' }}
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
run: npm run deploy

You did it!

Push your changes, and watch your brand new blog get deployed to firebase!

The pipeline output will contain your site's address, or you can hop back over to the firebase console and find it there.

You've just built an end-to-end, no bullshit, full-stack blog and even got it deployed!

Take a moment, pat yourself on the back, play around with what you've built. Iterate on it. Since you have a continuous delivery pipeline every commit to master will end up deployed with no effort on your part.

Now would also be a good time to checkout the pre-rendering. Feel free to view page source on any page.

The pre-rendering also adds two extra files to help you out with seo / know which pages are pre-rendered. They are "/sitemap.json" and "/sitemap.xml"; try fetching those as well. You may notice that the origin isn't quite right (http://localhost:7343), this is because the pre-rendering happens via an node express server at build time.

Run the configure command and paste your real origin when prompted for "baseUrl" to correct this.

fyord configure