scu-ssg v0.1.40
SSG - Summit Static Site Generator
Name: Summit Static Site Generator - scu-ssg
Project Abbreviation: scu-ssg
Developer Emails: nathan.maerz@summitcreditunion.com, payton.dickerson@summitcreditunion.com
NPM URL: https://www.npmjs.com/package/scu-ssg
Repo URL: https://bitbucket.org/summitcu/scu-ssg
Documentation URL: https://bitbucket.org/summitcu/scu-ssg
Description
scu-ssg is a light weight static site generator coupled primarily to Contentful with a built in client side preview generator. It is used most noticeably in Summit's website, but can also play a pivotal role in any application that utilizes Contentful and has any kind of static output such as Rates Central.
Content:
Global DSys/Contentful Team Documentation , Contentful Team Global Documentation, object Object,object Object
SSG - What is SSG?, What is an SSG?, The Summit SSG package, scu-ssg, is a static site generator. A static site generator creates all of the assets of a website or application, your HTML, CSS, JavaScript, in one complete build. All of these assets can then be deployed and statically hosted on a simple file server.
You can contrast this to a to a traditional SSR which is a server side render process. So for example, when a page is requested from a PHP driven website, every single page is created and delivered to the user on every request. Every request for that page possibly entails a server opening a database processing those results into a full HTML page, whereas an SSG process does all that all up front, all at one time.
The advantages of SSG are a significant increase in page loading performance for users, stronger security since the server does not have access to a database, and the ability to create a tighter application centric architecture for better testing and code reuse (versus most CMSes). The disadvantages being that any data persistence will have to be done with an API and each change to the content will entail a full rebuild.
JAM Stack - SSR Server Architecture
Netlify - JAM Stack Hosting Service
- SSG - How to Install, How to Install, 1) Create a folder and initialize a project (create package.json file)
yarn init
2) Install scu-ssg
yarn add scu-ssg
3) Add scripts to package.json:
"build": "scu-ssg build",
"build:prod": "scu-ssg build --env prod",
"build:types": "scu-ssg types --output ./src/@types/generated/contentful.d.ts"
"build:types" will create TypeScript typings of your entire Contentful Model.
4) .env
# scu-ssg information
CONTENTFUL_SPACE=[Contentful space]
CONTENTFUL_ENVIRONMENT=[Contentful environment]
CONTENTFUL_ACCESS_TOKEN=[Contentful CPA]
CONTENTFUL_MANAGEMENT_ACCESS_TOKEN=[Contentful CMA]
# typescript typings (also uses CONTENTFUL_ENVIRONMENT to pull from)
CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN=[Contentful CMA]
5) Set up your Contentful Model and add to SSG Controller. (see next)
- SSG - File Examples, File Examples, A barebones example of a working scu-ssg implementation only requires a few files, at least one Model, a View, and a Controller that maps everything together. Since scu-ssg only focuses on Contentful data, everything is tied together by Content Type ids established within Contentful.
BaseController
An SSG Controller is used to map Contentful Entries to their corresponding Models and Views via their Content Type id.
// src/Controller.ts
import { BaseController } from "scu-ssg";
import { ContentTypeId } from "./types";
import PageModel from "./model/PageModel";
import PageView from "./view/PageView";
import ContentView from "./view/ContentView";
export default class Controller extends BaseController {
// Various outputs during build process
debugSequence = false;
debugFileCreation = true;
debugImageCache = false;
debugStateToFile = true;
// Models mapped to Contentful Type Ids are used to
// figure out what needs to be loaded.
models = {
[ContentTypeId.page]: new PageModel(),
};
// Each Contentful Entry renders itself relative
// to the mapped View
views = {
[ContentTypeId.page]: new PageView(),
[ContentTypeId.contentView]: new ContentView(),
};
// For views that don't need to load anything and
// do NOT need to be previewed.
staticViews = [];
}
You will need a file to centralize Content Type ids. These need to directly correspond to the Content Type ids found in Contentful. The dynamically created Typescript typings do not output something like this.
// src/types.ts
export enum ContentTypeId {
page = 'page',
contentView = 'contentView',
content = 'content',
webPage = 'webPage'
}
BaseContentfulAPIModel
BaseContentfulAPIModel is the base class to give instructions as to how to load in Entries of a specific Content Type. The major use case is a simple configuration that loads in all Entries of that content type. All the referenced Entries within the loaded Entry will also be loaded by default.
// src/model/PageModel.ts
import { BaseContentfulAPIModel, BaseController } from "scu-ssg";
import { ContentTypeId } from "../types";
import type { EntryCollection } from "contentful";
export default class PageModel extends BaseContentfulAPIModel {
contentTypeId = ContentTypeId.page;
async loadAllEntries(
controller: BaseController
): Promise<void | EntryCollection<unknown>> {
return super.loadAllEntries(controller);
}
}
BaseView
BaseView manages the creation of a single output from a single Entry. It outputs a string (html usually, but can be JSON, CSV) and the various file configuration information such as extension and final url.
// src/view/PageView.ts
import { Entry } from "contentful";
import {
html,
BaseController,
BaseView,
renderEntry,
Extensions
} from "scu-ssg";
import { IPage } from "../@types/generated/contentful";
export default class PageView extends BaseView {
extension: Extensions = Extensions.html;
isFolder = true;// entry/index.html versus entry.html
async renderUrl(url: string, entry: IPage): Promise<string> {
// url is the slug or Entry id used to find this Entry
return entry.fields.slug;// final url for this Entry
}
async renderContent(
entity: Entry<any> | null | undefined,
controller: BaseController,
): Promise<string> {
const pageEntry = entity as unknown as IPage;
const components = pageEntry.fields.content?.map(
(contentView) => {
return renderEntry(controller, contentView);
}
);
// keep things asynchronous
const contentViewResults = await Promise.all(components);
return html`
<html>
<body>
${contentViewResults.join('')}
</body>
</html>`;
}
}
Page Entry references Content Entries, so a ContentView is needed. However, a ContentModel is NOT needed because Content Entries will be loaded by virtue of being referenced by a Page Entry.
// src/view/ContentView.ts
import { Entry } from "contentful";
import { BaseController, BaseView } from "scu-ssg";
import { IContentView } from "../@types/generated/contentful";
export default class ContentView extends BaseView {
async renderContent(
entity: Entry<any> | null | undefined,
controller: BaseController,
): Promise<string> {
const contentViewEntry = entity as unknown as IContentView;
const contentEntry = contentViewEntry.fields.content;
return `
<h1>
${contentViewEntry?.fields.title} and
${contentEntry?.fields.title} has rendered
</h1>`;
}
}
- SSG - Page Previews, Page Previews, Page previews are done in a browser with scu-ssg. A preview render is designed to load just those Entries that it takes to render that preview. It is NOT an efficient way to render a page, so it should not be used in any production situation.
How Entry is Found
If an Entry has a registered View, then the Controller will be able to present it's output. In other words, it doesn't have to be a Page to show it's output, a card or section can be preview rendered as well as long as it has a view (see rendering non page views).
It looks for entries in two ways: 1) Entry Id. 2) Slug. This is following a specific pattern of using 'url' Content Types. The Controller will first look for an Entry with a Content Type Id of 'url' and a field named 'slug' that matches the slug argument. It then looks for the first Entry that references this url Entry and returns that referencing Entry.
To run this, you need to initialize your Controller with the appropriate configurations (space, env, access keys) and then run a function called 'runSlug' on that Controller. You can either build an application that directly imports your main Controller, or you can import your Controller runtime via the controller found in the dist/_system
folder which is bundled into your final build. Note that you are using access keys, so be careful how you use this. Example:
import Controller from 'src/Controller';// your config'd Controller
const config = {
CONTENTFUL_SPACE: [Space],
CONTENTFUL_ENVIRONMENT: [Environment],
CONTENTFUL_ACCESS_TOKEN: [CDA],
CONTENTFUL_PREVIEW_ACCESS_TOKEN: [CPA],
CONTENTFUL_CONTENT_STATUS: 'preview',
WEBSITE_URL: [URL of website],
};
const controller = new Controller(config);
const slugInfo = await controller.runSlug(
[Slug or Entry Id],
// This is not the status of the loaded data, it is the status
// of the images...preview loads from CTFL, publish loads from local
ContenfulStatus.preview
);
/* Returns
export interface RenderedView {
url?: string,
content: string,
extension: Extensions,
isFolder?: boolean,
isError?: boolean,
}
*/
Rendering Non Page Views
Because the preview renderer can figure out how to render any Entry with a View, it is a good idea to output a more robust page even for those child Entries in order to server any needed CSS/JS that it takes to render that Entry in isolation. For example, this code is asking the controller if this Entry is the root Entry driving this render process and renders a more robust html template.
if (controller.isRootEntry(entry)) {
return await htmlBasicTplt(html, entry, controller);
}
return html;
- SSG - Build Sequence, Build Sequence, ### Two Rendering Sequences There are two rendering sequences in scu-ssg: 1) Full Build builds all requested Entries (ie pages) and is done on a server with NodeJS. 2) Preview Build builds an individual Entry on demand, usually in a browser, but can be done via NodeJS.
Five Stages
1) Build Triggered. The build is triggered either through a pipeline trigger (from a checkin for instance) or from a direct call in a browser for the preview build. 2) Load Process. All the required Entries are loaded here. This process is generally the same whether this is a full build or a preview build with the only difference being how many root Entries are loaded. Once those root Entries are loaded, all other referenced Entries are queued and loaded entirely in this process. The sequence does not continue without ALL needed Entries. 3) State Database Populated. This happens during the load process, but ultimately is a key/value reference using the Entry’s id as the key.
state = {
entries: {[key:string]: Entry<unknown>} = {
‘abc123’ = {sys:{...}, fields: {...}},
...
};
}
4) Render Process. After Load Process is complete, the Controller walks through all the entries in the state and looks for the corresponding View. If it finds a view it asks that view to render it and save it to the dist/ folder at the url returned via “renderUrl”. This means that there can be an output for Entries beyond just pages. This is good for a number of reasons such as:
- creating redirects for URL objects
- creating JSON of content Entries for others to consume and avoid having to go to Contentful.
5) Static Assets Deployed to Server. After Render Process is done, the dist/ folder is deployed to an S3 bucket via Terraform., object Object
SSG - Reading Debugging Output, Reading Debugging Output, Details of SSG Output. Note that this is output for both Full and Preview Builds., object Object
SSG - Exceptions, Exceptions, ### Pages w/o References to Needed Entries There are some pages that do not reference the Entries they need to build or even have an Entry to begin with. In the former case the preview for the page will fail because it won't have the Entries it needs to render. In the latter case the page will not be render at all. Depending on whether or not you require a preview of that page, you can either setup a static page which will work for full builds only, and if you do need a preview you will need to establish a placeholder Entry and add a custom query to that Entry's registered model.
Static Pages
An example of a static page would be a page that shows a compiled list of other pages. This View can be added directly to the "staticViews" property. This pattern will NOT work with preview builds (see custom queries), so use this only in situations where previews are not important.
export default class Controller extends BaseController {
models = {
...
};
views = {
...
};
staticViews = [new LocationsIndexJsonView()];
}
Custom Queries in Model
Generally you should work to have your page reference all of it's needed Entries, but this is not feasible in all situations. An example being a page made up of a dynamic list of pages based on a tag. In this situation where you still need a preview, you should:
- Create a placeholder Entry. This ensures that the preview build can find a starting point, an Entry, and then use the Content Type id to map to it's Model.
- Create a Model for that Entry with a custom query.
During the preview build process, when an Entry is found (via id or slug) the build process looks for a Model via the Content Type Id and then calls "customQuery". This function provides an opportunity to load in any non-referenced Entries. This doesn't have to be overly performant since previews should not be used in production.
A custom query looks like this:
async customQuery(
entity: Entry<unknown> | null | undefined,
controller: BaseController
): Promise<void> {
// controller already has a Contentful client
const client = controller.state.config.contentfulClient;
if (!client) return;
const config = {
content_type: ContentTypeId.articlePage,
include: CONTENTFUL_INCLUDE,
limit: 1000,
};
// Make sure to update stats
controller.state.context.totalCalls++;
const results = await client.getEntries(config);
// Let scu-ssg process results...there may be more to load
processContentfulResults(results, controller);
return;
}
Multiple Outputs from One Entry
You can register more than one View output, however, you must be careful to have each View output to a unique file url. This example produces a viable html output and a json output of the content (an API for others to pull from your website versus Contentful).
views = {
[ContentTypeId.website]: [
new WebsiteView(),
new JsonView()
],
};
Adding Hardcoded IDs
Some Entries are singletons or need a specific Entry that isn't referenced. For instance a website Entry would only ever have a single entry for a website and it wouldn't make a lot of sense to have each page reference a website Entry. In these situations you can register specific Entry ids to load via requiredEntities in a Model.
export const WEBSITE_ID = 'abc123';
export default class WebsiteModel extends BaseContentfulAPIModel {
contentTypeId = ContentTypeId.website;
requiredEntities = [
WEBSITE_ID,
];
}
Build Information
Dynamically built using contentful-readme-generator. Do not edit directly.
updated: 6/22/2023, 10:20:47 AM
built: 7/5/2023, 12:24:15 PM
space: 7gg213tt004u
environment: sandbox-readme
entity id: 2H0mLtfCSigvIiT6hxltbW
13 days ago
14 days ago
14 days ago
14 days ago
3 months ago
7 months ago
7 months ago
8 months ago
8 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
9 months ago
10 months ago
10 months ago
11 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
1 year ago
2 years ago
2 years ago
2 years ago
1 year ago
2 years ago
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago