├── .gitignore
├── LICENSE.txt
├── README.md
├── __tests__
└── fm.ts
├── package-lock.json
├── package.json
├── src
├── cli.ts
├── cmd.ts
├── fm.ts
├── fs.ts
├── server.ts
├── templates
│ ├── default
│ │ └── index.ts
│ └── index.ts
├── types.ts
└── utils.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | notes.md
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright 2021 Ben Reinhart
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # syte
2 |
3 | Syte is an opinionated static site generator. It's intended for simple use cases, e.g., personal sites and blogs.
4 |
5 | Syte takes static files of any kind, a config file, and your [ejs](https://ejs.co) and [markdown](https://www.markdownguide.org) files and compiles them into static HTML files.
6 |
7 | [Markdown](https://www.markdownguide.org) files are first preprocessed using ejs. This enables a more powerful developer experience by allowing programmatic access to the dynamic application environment from within your markdown content.
8 |
9 | ## Install
10 |
11 | ```
12 | npm install --global syte
13 | ```
14 |
15 | ## Quick start
16 |
17 | ```
18 | $ syte new mysite
19 | $ cd mysite
20 | ```
21 |
22 | This will create a new project called `mysite` with the following directory structure:
23 |
24 | ```
25 | mysite
26 | ├── layouts
27 | │ └── app.ejs
28 | ├── pages
29 | │ └── index.md
30 | ├── static
31 | │ └── app.css
32 | └── app.yaml
33 | ```
34 |
35 | Note:
36 |
37 | * The `layouts` directory, `pages` directory, and `app.yaml` file are mandatory. The `static` directory is where you can put any static files, like JavaScript, CSS, favicon.ico, CNAME, etc. The entire `static` folder will be copied as is into the root directory for the production build.
38 | * `app.yaml` can contain any arbitrary context you want and will be available in the ejs files.
39 | * The `pages` directory can contain any number of ejs (`.ejs`) or markdown (`.md`) files.
40 | * Pages can be nested arbitrarily deep. Their URLs will be the path to the file relative to the `pages` directory.
41 | * Pages are able to supply context and configuration via front matter (yaml with leading and trailing `---`). This context will be merged against the global context defined in `app.yaml`.
42 |
43 | Let's say we want to add some blog pages to our site with the following urls:
44 |
45 | * /blog
46 | * /blog/my-post
47 |
48 | ```
49 | $ mkdir pages/blog
50 | $ touch pages/blog/index.ejs
51 | $ touch pages/blog/my-post.md
52 | ```
53 |
54 | The resulting directory now looks like the following:
55 |
56 | ```
57 | mysite
58 | ├── layouts
59 | │ └── app.ejs
60 | ├── pages
61 | │ ├── blog
62 | │ │ ├── index.ejs
63 | │ │ └── my-post.md
64 | │ └── index.md
65 | ├── static
66 | │ └── app.css
67 | └── app.yaml
68 | ```
69 |
70 | In the `pages/blog/my-post.md` file we want to write a blog post:
71 |
72 | ```md
73 | ---
74 | title: My post
75 | ---
76 |
77 | # My Post
78 |
79 | This is my post.
80 | ```
81 |
82 | Notice that the file uses front matter to define a `title` property for the page. Properties defined in the front matter will be available to the templates during compilation.
83 |
84 | In our `pages/blog/index.ejs` page, we want to render a list of links to all blog posts:
85 |
86 | ```ejs
87 |
88 | <% for (const page of pages) { _%>
89 | <% if (page.urlPath.startsWith("/blog/")) { _%>
90 |
91 | <%= page.title %>
92 |
93 | <% } _%>
94 | <% } _%>
95 |
96 | ```
97 |
98 | To view our pages while we develop them, we'll start the development server:
99 |
100 | ```
101 | $ syte serve
102 | ```
103 |
104 | This will spin up your syte project by default on http://localhost:3500. In this case, the three pages available to us are:
105 |
106 | * http://localhost:3500
107 | * http://localhost:3500/blog
108 | * http://localhost:3500/blog/my-post
109 |
110 | Finally, when we're ready to deploy, we can compile the source into static files with the following command.
111 |
112 | ```
113 | $ syte build
114 | ```
115 |
116 | This will output a directory called `build` (the default, but can be changed with `-o` option) into the current working directory with the following structure:
117 |
118 | ```
119 | build
120 | ├── app.css
121 | ├── blog
122 | │ ├── my-post
123 | │ │ └── index.html
124 | │ └── index.html
125 | └── index.html
126 | └── rss.xml
127 | ```
128 |
129 | When deploying to some environments, you may need to prefix the urls with a root path (this is the case with some github pages sites). If you used the `pathTo` helper for all your relative url references, then you can build the site with a specified url path prefix. For example, if you are deploying to github pages for a repo named `your-gh-repo`, you would build your syte project with the following command:
130 |
131 | ```
132 | $ syte build --urlPathPrefix your-gh-repo
133 | ```
134 |
135 | ### RSS Feed
136 |
137 | If your app config contains a `base_url` entry, then a `rss.xml` RSS feed is generated by the `syte build` command. In order to populate feed items, your pages will need to have `date` and `title` frontmatter items.
138 |
139 | ```
140 | title: "My great post"
141 | date: "2021-09-02"
142 | ```
143 |
144 | ### Podcast RSS Feed
145 |
146 | Syte can also generate a podcast RSS feed with the required fields for the iTunes podcast directory. Here are the required fields in app config (with some examples):
147 |
148 | ```
149 | title: My Awesome Podcast
150 | base_url: https://example.com
151 | podcast_category: Technology
152 | podcast_language: en-us
153 | podcast_subtitle: The greatest podcast of all time
154 | podcast_author: Jane Smith
155 | podcast_summary: The greatest podcast of all time, featuring Jane Smith
156 | podcast_explicit: 'no'
157 | podcast_email: foo@example.com
158 | podcast_img_url: https://example.com/logo.jpg
159 | ```
160 |
161 | And each page will need the following frontmatter (with examples):
162 |
163 | ```
164 | title: "1: This is an episode title"
165 | date: "2021-12-07"
166 | episode_url: 'https://example/audio/001_this_is_an_episode.mp3'
167 | episode_duration: '4960'
168 | episode_length: '99215166'
169 | episode_summary: This episode is about being awesome.
170 | episode_explict: 'yes'
171 | ```
172 |
173 | The only slightly confusing fields here are likely `episode_duration` and `episode_length`. Episode duration refers to the length of your episode's mp3 file in seconds and can be calculated with this command `ffprobe -show_entries stream=duration -of compact=p=0:nk=1 -v fatal MY_AWESOME_EPISODE.mp3`. Episode length refers to the number of bytes of your episode's mp3 file and can be calculated with `wc -c < MY_AWESOME_EPISODE.mp3`.
174 |
175 | The "show notes" for each episode will be generated from the page contents (after the frontmatter).
176 |
177 | ### Farcaster auto-cast new posts
178 |
179 | Syte can also "cast" your pages to [Farcaster](https://farcaster.xyz) - a "[sufficiently decentralized](https://www.varunsrinivasan.com/2022/01/11/sufficient-decentralization-for-social-networks)" social networking protocol. You'll need a Farcaster username and its [corresponding private key](https://farcasterxyz.notion.site/Find-your-Farcaster-private-key-c409a0c2b036467d8f5172ff8df3bc9d).
180 |
181 | You casts will consist of the page title and the page url, and should only send casts for new posts (or if the title/url changes), so this will run as part of your Syte's deploy process (like the RSS feed generation).
182 |
183 | To auto-cast, your app config will need:
184 | ```
185 | base_url: https://example.com
186 | farcaster_username: whatrocks
187 | ```
188 |
189 | You post frontmatter must include:
190 | ```
191 | title: "My awesome page"
192 | date: "2022-03-28"
193 | ```
194 |
195 | Finally, you'll need to add an environment variable called `FARCASTER_MNEMONIC` with your Farcaster username's mnemonic / seed phrase. This is not ideal and will be likely improved with upcoming Farcaster protocol features, but this does work for now.
196 |
197 |
--------------------------------------------------------------------------------
/__tests__/fm.ts:
--------------------------------------------------------------------------------
1 | import fm from "../src/fm";
2 |
3 | const noFrontMatter = `# Heading 1
4 |
5 | Paragraph text.
6 | `;
7 |
8 | const emptyFrontMatterContent = `---
9 | ---
10 | # Heading 1
11 |
12 | Paragraph text.
13 | `;
14 |
15 | const whitespaceFrontMatterContent = `---
16 |
17 | ---
18 | # Heading 1
19 |
20 | Paragraph text.
21 | `;
22 |
23 | const frontMatterWithoutClosingTag = `---
24 |
25 | # Heading 1
26 |
27 | Paragraph text.
28 | `;
29 |
30 | const invalidFrontMatterContent = `---
31 | non-object
32 | ---
33 | # Heading 1
34 |
35 | Paragraph text.
36 | `;
37 |
38 | const validFrontMatter = `---
39 | title: My page
40 | layout: blog
41 | arbitraryKey: arbitrary value
42 | ---
43 | # Heading 1
44 |
45 | Paragraph text.
46 | `;
47 |
48 | describe("fm.parse", () => {
49 | it("parses contents with no front matter", () => {
50 | const [frontMatter, contents] = fm.parse(noFrontMatter);
51 | expect(frontMatter).toEqual({});
52 | expect(contents).toEqual("# Heading 1\n\nParagraph text.\n");
53 | });
54 |
55 | it("parses contents with empty front matter", () => {
56 | const [frontMatter, contents] = fm.parse(emptyFrontMatterContent);
57 | expect(frontMatter).toEqual({});
58 | expect(contents).toEqual("# Heading 1\n\nParagraph text.\n");
59 | });
60 |
61 | it("parses contents with whitespace only front matter", () => {
62 | const [frontMatter, contents] = fm.parse(whitespaceFrontMatterContent);
63 | expect(frontMatter).toEqual({});
64 | expect(contents).toEqual("# Heading 1\n\nParagraph text.\n");
65 | });
66 |
67 | it("throws an error when no closing tag is found", () => {
68 | expect(() => {
69 | fm.parse(frontMatterWithoutClosingTag);
70 | }).toThrow(/EOF reached without closing front matter tag/);
71 | });
72 |
73 | it("throws an error when front matter content is invalid", () => {
74 | expect(() => {
75 | console.log(fm.parse(invalidFrontMatterContent));
76 | }).toThrow(/^Front matter must be a yaml object/);
77 | });
78 |
79 | it("parses valid front matter", () => {
80 | const [frontMatter, contents] = fm.parse(validFrontMatter);
81 | expect(frontMatter).toEqual({
82 | title: "My page",
83 | layout: "blog",
84 | arbitraryKey: "arbitrary value",
85 | });
86 | expect(contents).toEqual("# Heading 1\n\nParagraph text.\n");
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "syte",
3 | "version": "0.0.1-beta.13",
4 | "description": "Minimalist static site generator.",
5 | "keywords": [
6 | "static",
7 | "blog",
8 | "generator",
9 | "markdown",
10 | "ejs",
11 | "website",
12 | "jekyll",
13 | "jamstack"
14 | ],
15 | "main": "dist/cmd.js",
16 | "author": "Ben Reinhart",
17 | "license": "MIT",
18 | "bin": {
19 | "syte": "dist/cli.js"
20 | },
21 | "scripts": {
22 | "build": "tsc --outDir dist",
23 | "clean": "rm -rf ./dist",
24 | "prebuild": "npm run clean",
25 | "prepublishOnly": "npm run build",
26 | "test": "jest"
27 | },
28 | "files": [
29 | "dist",
30 | "LICENSE.txt",
31 | "README.md"
32 | ],
33 | "prettier": {
34 | "printWidth": 100,
35 | "semi": true
36 | },
37 | "jest": {
38 | "preset": "ts-jest",
39 | "testEnvironment": "node"
40 | },
41 | "dependencies": {
42 | "chokidar": "^3.5.1",
43 | "ejs": "^3.1.6",
44 | "farcaster-feed": "^0.3.0",
45 | "fast-glob": "^3.2.5",
46 | "js-yaml": "^3.14.1",
47 | "marked": "^2.0.0",
48 | "ncp": "^2.0.0",
49 | "rss": "^1.2.2",
50 | "send": "^0.17.1",
51 | "yargs": "^16.2.0"
52 | },
53 | "devDependencies": {
54 | "@types/ejs": "^3.0.6",
55 | "@types/jest": "^26.0.20",
56 | "@types/js-yaml": "^4.0.0",
57 | "@types/marked": "^1.2.2",
58 | "@types/ncp": "^2.0.4",
59 | "@types/node": "^14.14.28",
60 | "@types/rss": "^0.0.29",
61 | "@types/send": "^0.14.7",
62 | "@types/yargs": "^16.0.0",
63 | "jest": "^26.6.3",
64 | "ts-jest": "^26.5.1",
65 | "ts-node": "^9.1.1",
66 | "typescript": "^4.2.2"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import yargs, { Argv } from "yargs";
4 | import cmd from "./cmd";
5 |
6 | yargs(process.argv.slice(2))
7 | .command({
8 | command: "new [path]",
9 | describe: "Generate new syte project",
10 | builder: (yargs: Argv) => {
11 | return yargs.positional("path", {
12 | describe: "Where to create syte project",
13 | default: ".",
14 | });
15 | },
16 | handler: cmd.new,
17 | })
18 | .command({
19 | command: "serve [path]",
20 | describe: "Serve the given syte project",
21 | builder: (yargs: Argv) => {
22 | return yargs
23 | .positional("path", {
24 | describe: "Path to the root directory of syte project",
25 | default: ".",
26 | })
27 | .options({
28 | port: {
29 | alias: "p",
30 | describe: "Port to serve on",
31 | default: 3500,
32 | },
33 | });
34 | },
35 | handler: cmd.serve,
36 | })
37 | .command({
38 | command: "build [path]",
39 | describe: "Compile syte project into a static site",
40 | builder: (yargs: Argv) => {
41 | return yargs
42 | .positional("path", {
43 | describe: "Path to the root directory of syte project",
44 | default: ".",
45 | })
46 | .options({
47 | outputPath: {
48 | alias: "o",
49 | describe: "Path where syte site will be written",
50 | default: "build",
51 | },
52 | urlPathPrefix: {
53 | describe: "Specify a path to prefix the url path",
54 | default: "/",
55 | },
56 | });
57 | },
58 | handler: cmd.build,
59 | })
60 | .help().argv;
61 |
--------------------------------------------------------------------------------
/src/cmd.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import ejs from "ejs";
3 | import { FarcasterFeed } from "farcaster-feed";
4 | import marked from "marked";
5 | import RSS from "rss";
6 | import send from "send";
7 | import templates from "./templates";
8 | import fm from "./fm";
9 | import fs from "./fs";
10 | import { ContextType, FileType, LayoutType, PageType, ObjectType, SyteType } from "./types";
11 | import { isObject, isString, loadYamlObject, shallowMerge } from "./utils";
12 | import server from "./server";
13 |
14 | function urlPathJoin(...paths: string[]) {
15 | let path = paths.join("/");
16 |
17 | // Replace duplicate slashes with one slash
18 | path = path.replace(/\/+/g, "/");
19 |
20 | // Remove leading and trailing slashes
21 | path = path.replace(/^\/|\/$/g, "");
22 |
23 | return `/${path}`;
24 | }
25 |
26 | function getLinkHref(link: string, appBaseURL: string): string {
27 | return new URL(link, appBaseURL).href;
28 | }
29 |
30 | const URI_RE = new RegExp("^[-a-z]+://|^(?:cid|data):|^//");
31 |
32 | const RSS_FILENAME = "rss.xml";
33 | const PODCAST_RSS_FILENAME = "podcast.xml";
34 |
35 | function buildPathTo(pathRoot: string) {
36 | return (source: string | PageType) => {
37 | if (isString(source)) {
38 | const strSource = source as string;
39 | if (URI_RE.test(strSource)) {
40 | return strSource;
41 | } else {
42 | return urlPathJoin(pathRoot, strSource);
43 | }
44 | } else if (isObject(source)) {
45 | const pageSource = source as PageType;
46 | return urlPathJoin(pathRoot, pageSource.urlPath);
47 | } else {
48 | throw new TypeError(`Expected string or page object, received '${source}'`);
49 | }
50 | };
51 | }
52 |
53 | function constructUrlPath(projectPagesPath: string, filePath: string) {
54 | const relativeFilePath = path.relative(projectPagesPath, filePath);
55 |
56 | let urlPath = relativeFilePath;
57 |
58 | // Remove supported extensions (.ejs and .md), e.g.:
59 | //
60 | // foo/bar.js => foo/bar.js
61 | // foo/bar.ejs => foo/bar
62 | // foo/bar.md => foo/bar
63 | // foo/bar.baz.ejs => foo/bar.baz
64 | //
65 | urlPath = urlPath.replace(/\.md$|\.ejs$/, "");
66 |
67 | // urlPath should be the full path of the url, so ensure a '/' prefix
68 | urlPath = urlPath.startsWith("/") ? urlPath : `/${urlPath}`;
69 |
70 | // If the file is an 'index' file, then omit 'index'
71 | urlPath = path.basename(urlPath) === "index" ? path.dirname(urlPath) : urlPath;
72 |
73 | return urlPath;
74 | }
75 |
76 | function isMarkdown(page: PageType) {
77 | return path.extname(page.filePath) === ".md";
78 | }
79 |
80 | function isAppYaml(projectPath: string, filePath: string) {
81 | const relativePath = path.relative(projectPath, filePath);
82 | return relativePath === "app.yaml";
83 | }
84 |
85 | function isLayout(projectPath: string, filePath: string) {
86 | const relativePath = path.relative(projectPath, filePath);
87 | return /^layouts\/.+/.test(relativePath);
88 | }
89 |
90 | function isPage(projectPath: string, filePath: string) {
91 | const relativePath = path.relative(projectPath, filePath);
92 | return /^pages\/.+/.test(relativePath);
93 | }
94 |
95 | function constructLayout(projectLayoutsPath: string, file: FileType): LayoutType {
96 | const name = path.relative(projectLayoutsPath, file.filePath).replace(/\.ejs$/, "");
97 | return Object.freeze({ name, filePath: file.filePath, contents: file.contents });
98 | }
99 |
100 | function constructPage(projectPagesPath: string, file: FileType): PageType {
101 | const filePath = file.filePath;
102 | const urlPath = constructUrlPath(projectPagesPath, file.filePath);
103 | const [context, contents] = fm.parse(file.contents);
104 | context.urlPath = urlPath;
105 | return Object.freeze({ filePath, urlPath, contents, context });
106 | }
107 |
108 | async function readApp(filePath: string) {
109 | const appFile = await fs.read(filePath);
110 | return loadYamlObject(appFile.contents);
111 | }
112 |
113 | async function readLayout(projectLayoutsPath: string, filePath: string) {
114 | const layoutFile = await fs.read(filePath);
115 | return constructLayout(projectLayoutsPath, layoutFile);
116 | }
117 |
118 | async function readPage(projectPagesPath: string, filePath: string) {
119 | const pageFile = await fs.read(filePath);
120 | return constructPage(projectPagesPath, pageFile);
121 | }
122 |
123 | async function readAllLayouts(projectLayoutsPath: string, layoutPaths: string[]) {
124 | const promises = layoutPaths.map((filePath) => readLayout(projectLayoutsPath, filePath));
125 | return Promise.all(promises);
126 | }
127 |
128 | async function readAllPages(projectPagesPath: string, pagesPath: string[]) {
129 | const promises = pagesPath.map((filePath) => readPage(projectPagesPath, filePath));
130 | return Promise.all(promises);
131 | }
132 |
133 | interface PageRenderOptions {
134 | urlPathPrefix: string;
135 | }
136 |
137 | function renderPage(
138 | page: PageType,
139 | appContext: ObjectType,
140 | layouts: LayoutType[],
141 | pages: PageType[],
142 | options: PageRenderOptions
143 | ) {
144 | const context: ContextType = shallowMerge(appContext, page.context, {
145 | pathTo: buildPathTo(options.urlPathPrefix),
146 | pages: pages.map((page) => page.context),
147 | });
148 |
149 | context.body = ejs.render(page.contents, context);
150 |
151 | if (isMarkdown(page)) {
152 | context.body = marked(context.body);
153 | }
154 |
155 | const layoutName = page.context.layout || appContext.layout;
156 | const layout = layouts.find((layout) => layout.name === layoutName);
157 | if (!layout) {
158 | throw new Error(`${layoutName} layout doesn't exist`);
159 | }
160 |
161 | return ejs.render(layout.contents, context);
162 | }
163 |
164 | function renderPageContentsForRSS(page: PageType, appBaseURL: string) {
165 | const renderer = new marked.Renderer();
166 | renderer.link = (href, title, text) => {
167 | const absoluteHref = getLinkHref(href as string, appBaseURL);
168 | return `${text} `;
169 | };
170 | renderer.image = (href, title, text) => {
171 | const absoluteHref = getLinkHref(href as string, appBaseURL);
172 | return ` `;
173 | };
174 | marked.setOptions({ renderer });
175 | return marked(page.contents);
176 | }
177 |
178 | interface NewCmdArgvType {
179 | path: string;
180 | }
181 |
182 | async function cmdNew(argv: NewCmdArgvType) {
183 | const projectPath = path.resolve(argv.path);
184 | const projectName = path.basename(projectPath);
185 |
186 | await Promise.all([
187 | fs.mkdirp(path.join(projectPath, "static")),
188 | fs.mkdirp(path.join(projectPath, "layouts")),
189 | fs.mkdirp(path.join(projectPath, "pages")),
190 | ]);
191 |
192 | await templates.default.create(projectPath, projectName);
193 | }
194 |
195 | interface ServeCmdArgvType {
196 | path: string;
197 | port: number;
198 | }
199 |
200 | async function cmdServe(argv: ServeCmdArgvType) {
201 | const chokidar = require("chokidar");
202 |
203 | const projectPath = path.resolve(argv.path);
204 |
205 | const projectAppPath = path.join(projectPath, "app.yaml");
206 | if (!(await fs.exists(projectAppPath))) {
207 | console.error(`Cannot find app.yaml at ${projectAppPath}`);
208 | process.exit(1);
209 | }
210 |
211 | const projectLayoutsPath = path.join(projectPath, "layouts");
212 | if (!(await fs.exists(projectLayoutsPath))) {
213 | console.error(`Cannot find layouts at ${projectLayoutsPath}`);
214 | process.exit(1);
215 | }
216 |
217 | const projectPagesPath = path.join(projectPath, "pages");
218 | if (!(await fs.exists(projectPagesPath))) {
219 | console.error(`Cannot find pages at ${projectPagesPath}`);
220 | process.exit(1);
221 | }
222 |
223 | const syte: SyteType = {
224 | app: {},
225 | layouts: [],
226 | pages: [],
227 | };
228 |
229 | const watcher = chokidar.watch([
230 | `${projectPath}/app.yaml`,
231 | `${projectPath}/layouts/**/*.ejs`,
232 | `${projectPath}/pages/**/*.(ejs|md)`,
233 | ]);
234 |
235 | watcher.on("add", async (filePath: string) => {
236 | if (isAppYaml(projectPath, filePath)) {
237 | const newApp = await readApp(filePath);
238 | syte.app = newApp;
239 | } else if (isLayout(projectPath, filePath)) {
240 | const newLayout = await readLayout(projectLayoutsPath, filePath);
241 | syte.layouts.push(newLayout);
242 | } else if (isPage(projectPath, filePath)) {
243 | const newPage = await readPage(projectPagesPath, filePath);
244 | syte.pages.push(newPage);
245 | }
246 | });
247 |
248 | watcher.on("change", async (filePath: string) => {
249 | if (isAppYaml(projectPath, filePath)) {
250 | const updatedApp = await readApp(filePath);
251 | syte.app = updatedApp;
252 | } else if (isLayout(projectPath, filePath)) {
253 | const updatedLayout = await readLayout(projectLayoutsPath, filePath);
254 | syte.layouts = syte.layouts.map((layout) => {
255 | return layout.filePath === updatedLayout.filePath ? updatedLayout : layout;
256 | });
257 | } else if (isPage(projectPath, filePath)) {
258 | const updatedPage = await readPage(projectPagesPath, filePath);
259 | syte.pages = syte.pages.map((page) => {
260 | return page.filePath === updatedPage.filePath ? updatedPage : page;
261 | });
262 | }
263 | });
264 |
265 | watcher.on("unlink", async (filePath: string) => {
266 | if (isLayout(projectPath, filePath)) {
267 | syte.layouts = syte.layouts.filter((lf) => {
268 | return lf.filePath !== filePath;
269 | });
270 | } else if (isPage(projectPath, filePath)) {
271 | syte.pages = syte.pages.filter((p) => {
272 | return p.filePath !== filePath;
273 | });
274 | }
275 | });
276 |
277 | server.serve(argv.port, async (req, res) => {
278 | const url = new URL(req.url as string, `http://${req.headers.host}`);
279 |
280 | const urlPath = url.pathname === "/" ? url.pathname : url.pathname.replace(/\/+$/, "");
281 | const page = syte.pages.find((page) => page.urlPath === urlPath);
282 | if (page !== undefined) {
283 | const body = renderPage(page, syte.app, syte.layouts, syte.pages, { urlPathPrefix: "/" });
284 | res.writeHead(200, { "Content-Type": "text/html; charset=UTF-8" });
285 | res.end(body);
286 | return;
287 | }
288 |
289 | const staticFilePath = path.join(projectPath, "static", url.pathname);
290 | if (await fs.exists(staticFilePath)) {
291 | send(req, staticFilePath).pipe(res);
292 | return;
293 | }
294 |
295 | res.writeHead(404);
296 | res.end("Not Found");
297 | });
298 | }
299 |
300 | interface BuildCmdArgvType {
301 | path: string;
302 | outputPath: string;
303 | urlPathPrefix: string;
304 | }
305 |
306 | async function cmdBuild(argv: BuildCmdArgvType) {
307 | const projectPath = path.resolve(argv.path);
308 |
309 | const projectAppPath = path.join(projectPath, "app.yaml");
310 | if (!(await fs.exists(projectAppPath))) {
311 | console.error(`Cannot find app context at ${projectAppPath}`);
312 | process.exit(1);
313 | }
314 | const appContext = await readApp(projectAppPath);
315 |
316 | const projectLayoutsPath = path.join(projectPath, "layouts");
317 | const layoutPaths = await fs.glob(`${projectLayoutsPath}/**/*.ejs`);
318 | if (layoutPaths.length === 0) {
319 | console.error(`Cannot find layouts at ${projectLayoutsPath}`);
320 | process.exit(1);
321 | }
322 | const layouts = await readAllLayouts(projectLayoutsPath, layoutPaths);
323 |
324 | const projectPagesPath = path.join(projectPath, "pages");
325 | const pagePaths = await fs.glob(`${projectPagesPath}/**/*.(ejs|md)`);
326 | if (pagePaths.length === 0) {
327 | console.error(`Cannot find pages at ${projectPagesPath}`);
328 | process.exit(1);
329 | }
330 | const pages = await readAllPages(projectPagesPath, pagePaths);
331 |
332 | const outputPath = path.resolve(argv.outputPath);
333 | await fs.mkdirp(outputPath);
334 |
335 | const buildPages = () => {
336 | return pages.map(async (page) => {
337 | const pageContents = renderPage(page, appContext, layouts, pages, argv);
338 | const pageOutputDirPath = path.join(outputPath, page.urlPath);
339 | await fs.mkdirp(pageOutputDirPath);
340 | const filePath = path.join(pageOutputDirPath, "index.html");
341 | await fs.write(filePath, pageContents);
342 | });
343 | };
344 |
345 | const buildRssFeed = () => {
346 | const rssPath = path.join(outputPath, RSS_FILENAME);
347 | const feed = new RSS({
348 | title: appContext.title,
349 | description: appContext.title,
350 | feed_url: `${appContext.base_url}/${RSS_FILENAME}`,
351 | site_url: appContext.base_url,
352 | pubDate: new Date(),
353 | ttl: 60,
354 | });
355 | pages
356 | .filter((page) => page.context.date && page.context.title)
357 | .sort((a, b) => new Date(b.context.date).getTime() - new Date(a.context.date).getTime())
358 | .map((page) => {
359 | const contents = renderPageContentsForRSS(page, appContext.base_url);
360 | feed.item({
361 | title: page.context.title,
362 | description: contents,
363 | url: `${appContext.base_url}${page.urlPath}`,
364 | date: page.context.date,
365 | });
366 | });
367 | return fs.write(rssPath, feed.xml({ indent: true }));
368 | };
369 |
370 | const castPostsToFarcaster = (username: string, privateKey: string) => {
371 | const farcaster = new FarcasterFeed(username, privateKey);
372 | const posts = pages
373 | .filter((page) => page.context.title && page.context.date)
374 | .sort((a, b) => new Date(a.context.date).getTime() - new Date(b.context.date).getTime())
375 | .map((page) => ({ title: page.context.title, url: `${appContext.base_url}${page.urlPath}` }));
376 | return farcaster.castPosts(posts);
377 | };
378 |
379 | const buildPodcastRssFeed = () => {
380 | const podcastRssPath = path.join(outputPath, PODCAST_RSS_FILENAME);
381 | const feed = new RSS({
382 | title: appContext.title,
383 | description: appContext.podcast_subtitle,
384 | categories: [appContext.podcast_category],
385 | language: appContext.podcast_language || "en-us",
386 | feed_url: `${appContext.base_url}/${PODCAST_RSS_FILENAME}`,
387 | site_url: appContext.base_url,
388 | pubDate: new Date(),
389 | generator: "Syte",
390 | ttl: 60,
391 | custom_namespaces: {
392 | itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd",
393 | },
394 | custom_elements: [
395 | { "itunes:subtitle": appContext.podcast_subtitle },
396 | { "itunes:author": appContext.podcast_author },
397 | { "itunes:summary": appContext.podcast_summary },
398 | { "itunes:explicit": appContext.podcast_explicit },
399 | {
400 | "itunes:owner": [
401 | { "itunes:name": appContext.podcast_author },
402 | { "itunes:email": appContext.podcast_email },
403 | ],
404 | },
405 | {
406 | "itunes:image": {
407 | _attr: {
408 | href: appContext.podcast_img_url,
409 | },
410 | },
411 | },
412 | {
413 | "itunes:category": [
414 | {
415 | _attr: {
416 | text: appContext.podcast_category,
417 | },
418 | },
419 | ],
420 | },
421 | ],
422 | });
423 | pages
424 | .filter((page) => page.context.date && page.context.title && page.context.episode_url)
425 | .sort((a, b) => new Date(b.context.date).getTime() - new Date(a.context.date).getTime())
426 | .map((page) => {
427 | const contents = renderPageContentsForRSS(page, appContext.base_url);
428 | feed.item({
429 | title: page.context.title,
430 | description: contents, // displays as "Show Notes"
431 | url: `${appContext.base_url}${page.urlPath}`,
432 | date: page.context.date,
433 | categories: [appContext.podcast_category],
434 | enclosure: { url: page.context.episode_url, size: page.context.episode_length },
435 | custom_elements: [
436 | { "itunes:author": appContext.podcast_author },
437 | { "itunes:title": page.context.title },
438 | { "itunes:duration": page.context.episode_duration },
439 | { "itunes:summary": page.context.episode_summary },
440 | { "itunes:subtitle": page.context.episode_summary },
441 | { "itunes:explicit": page.context.episode_explict },
442 | {
443 | "itunes:image": {
444 | _attr: {
445 | href: appContext.podcast_img_url,
446 | },
447 | },
448 | },
449 | ],
450 | });
451 | });
452 | return fs.write(podcastRssPath, feed.xml({ indent: true }));
453 | };
454 |
455 | const copyStatic = () => {
456 | const source = path.join(projectPath, "static");
457 | const destination = path.join(outputPath);
458 | return fs.copy(source, destination);
459 | };
460 |
461 | const promises = [copyStatic(), ...buildPages()];
462 | if (appContext.base_url) {
463 | promises.push(buildRssFeed());
464 | if (appContext.podcast_author) {
465 | promises.push(buildPodcastRssFeed());
466 | }
467 | if (appContext.farcaster_username && process.env.FARCASTER_MNEMONIC) {
468 | promises.push(
469 | castPostsToFarcaster(appContext.farcaster_username, process.env.FARCASTER_MNEMONIC)
470 | );
471 | }
472 | }
473 |
474 | await Promise.all(promises);
475 | }
476 |
477 | export default {
478 | new: cmdNew,
479 | serve: cmdServe,
480 | build: cmdBuild,
481 | };
482 |
--------------------------------------------------------------------------------
/src/fm.ts:
--------------------------------------------------------------------------------
1 | import { ObjectType } from "./types";
2 | import { loadYamlObject } from "./utils";
3 |
4 | const FM_OPEN_TAG_RE = /^---\s*$/;
5 | const FM_CLOSE_TAG_RE = /^---\s*$/;
6 |
7 | function parse(text: string): [ObjectType, string] {
8 | const lines = text.split("\n");
9 | const linesLength = lines.length;
10 | const fmLines = [];
11 | const contentLines = [];
12 |
13 | let hasSeenFMOpenTag = false;
14 | let hasSeenFMCloseTag = false;
15 |
16 | for (let i = 0; i < linesLength; ++i) {
17 | const line = lines[i];
18 |
19 | if (!hasSeenFMOpenTag) {
20 | const lineIsFMOpenTag = FM_OPEN_TAG_RE.test(line);
21 |
22 | if (lineIsFMOpenTag) {
23 | hasSeenFMOpenTag = true;
24 | } else {
25 | return [{}, text];
26 | }
27 | } else if (hasSeenFMOpenTag && !hasSeenFMCloseTag) {
28 | const lineIsFMCloseTag = FM_CLOSE_TAG_RE.test(line);
29 |
30 | if (lineIsFMCloseTag) {
31 | hasSeenFMCloseTag = true;
32 | } else {
33 | fmLines.push(line);
34 | }
35 | } else {
36 | contentLines.push(line);
37 | }
38 | }
39 |
40 | if (hasSeenFMOpenTag && !hasSeenFMCloseTag) {
41 | throw new Error("EOF reached without closing front matter tag");
42 | }
43 |
44 | const contents = contentLines.join("\n");
45 | const contextStr = fmLines.join("\n");
46 |
47 | try {
48 | const context = loadYamlObject(contextStr);
49 | return [context, contents];
50 | } catch (e) {
51 | throw new Error(`Front matter must be a yaml object but found: ${contextStr}`);
52 | }
53 | }
54 |
55 | export default { parse };
56 |
--------------------------------------------------------------------------------
/src/fs.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs/promises";
2 | import fg from "fast-glob";
3 | import { ncp } from "ncp";
4 | import { constants } from "fs";
5 | import { FileType } from "./types";
6 |
7 | export default {
8 | async exists(path: string) {
9 | try {
10 | await fs.access(path, constants.F_OK);
11 | return true;
12 | } catch {
13 | return false;
14 | }
15 | },
16 |
17 | async read(filePath: string): Promise {
18 | const contents = await fs.readFile(filePath, { encoding: "utf8" });
19 | return Object.freeze({ filePath, contents });
20 | },
21 |
22 | glob(pattern: string) {
23 | return fg(pattern);
24 | },
25 |
26 | write(path: string, data: string) {
27 | return fs.writeFile(path, data, { encoding: "utf8" });
28 | },
29 |
30 | mkdirp(path: string) {
31 | return fs.mkdir(path, { recursive: true });
32 | },
33 |
34 | copy(source: string, destination: string) {
35 | return new Promise((resolve, reject) => {
36 | ncp(source, destination, (errors) => {
37 | return errors !== null ? reject(errors) : resolve();
38 | });
39 | });
40 | },
41 | };
42 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import http from "http";
2 |
3 | function errorTemplate(method: string, url: string, error: Error) {
4 | let errorMessage;
5 |
6 | errorMessage = error.stack || String(error);
7 | errorMessage = errorMessage.replace(/>/g, ">");
8 | errorMessage = errorMessage.replace(/
12 |
13 |
14 | Syte error
15 |
25 |
26 |
27 | Error while serving ${method.toUpperCase()} ${url}
28 |
29 |
${errorMessage}
30 |
31 |
32 |
33 | `;
34 | }
35 |
36 | function serve(
37 | port: number,
38 | render: (req: http.IncomingMessage, res: http.ServerResponse) => void
39 | ) {
40 | const server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
41 | try {
42 | render(req, res);
43 | } catch (e) {
44 | res.writeHead(500);
45 | res.end(errorTemplate(req.method as string, req.url as string, e));
46 | }
47 | });
48 |
49 | server.listen(port, "localhost", () => {
50 | console.log(`Your syte is being served at http://localhost:${port}`);
51 | });
52 | }
53 |
54 | export default { serve };
55 |
--------------------------------------------------------------------------------
/src/templates/default/index.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import fs from "../../fs";
3 | import yaml from "js-yaml";
4 |
5 | function appContext(projectName: string, layoutName: string) {
6 | return yaml.dump({
7 | layout: layoutName,
8 | title: projectName,
9 | });
10 | }
11 |
12 | function appCss() {
13 | return `* {
14 | box-sizing: border-box;
15 | }
16 | `;
17 | }
18 |
19 | function appLayout() {
20 | return `
21 |
22 |
23 |
24 | <%= title %>
25 | " rel="stylesheet">
26 |
27 |
28 | <%- body %>
29 |
30 |
31 | `;
32 | }
33 |
34 | function indexPage() {
35 | return `---
36 | title: Index Page
37 | ---
38 | # Index page
39 |
40 | This is your syte. It has <%= pages.length %> page(s).
41 |
42 | Navigation:
43 | <% for (const page of pages) { _%>
44 | * [<%= page.title || pathTo(page) %>](<%= pathTo(page) %>)
45 | <% } _%>
46 |
47 | ## TODO
48 |
49 | - [X] Generate syte project
50 | - [ ] Customize generated templates
51 | - [ ] Deploy!
52 | `;
53 | }
54 |
55 | async function create(projectPath: string, projectName: string) {
56 | return Promise.all(
57 | [
58 | async () => {
59 | const appContextPath = path.join(projectPath, "app.yaml");
60 | if (!(await fs.exists(appContextPath))) {
61 | await fs.write(appContextPath, appContext(projectName, "app"));
62 | }
63 | },
64 | async () => {
65 | const appCssPath = path.join(projectPath, "static", "app.css");
66 | if (!(await fs.exists(appCssPath))) {
67 | await fs.write(appCssPath, appCss());
68 | }
69 | },
70 | async () => {
71 | const appLayoutPath = path.join(projectPath, "layouts", "app.ejs");
72 | if (!(await fs.exists(appLayoutPath))) {
73 | await fs.write(appLayoutPath, appLayout());
74 | }
75 | },
76 | async () => {
77 | const indexPagePath = path.join(projectPath, "pages", "index.md");
78 | if (!(await fs.exists(indexPagePath))) {
79 | await fs.write(indexPagePath, indexPage());
80 | }
81 | },
82 | ].map((f) => f())
83 | );
84 | }
85 |
86 | export default { create };
87 |
--------------------------------------------------------------------------------
/src/templates/index.ts:
--------------------------------------------------------------------------------
1 | import defaultTemplate from "./default";
2 |
3 | export default {
4 | default: defaultTemplate,
5 | };
6 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type ObjectType = {
2 | [key: string]: any;
3 | };
4 |
5 | export interface FileType {
6 | filePath: string;
7 | contents: string;
8 | }
9 |
10 | export interface LayoutType extends FileType {
11 | name: string;
12 | }
13 |
14 | export interface PageType extends FileType {
15 | urlPath: string;
16 | context: ObjectType;
17 | }
18 |
19 | export interface SyteType {
20 | app: ObjectType;
21 | layouts: LayoutType[];
22 | pages: PageType[];
23 | }
24 |
25 | export type ContextType = ObjectType;
26 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import yaml from "js-yaml";
2 | import { ObjectType } from "./types";
3 |
4 | const NON_WHITESPACE_RE = /\S/;
5 |
6 | export function isBlank(s: string) {
7 | return !NON_WHITESPACE_RE.test(s);
8 | }
9 |
10 | export function shallowMerge(...o: ObjectType[]) {
11 | return Object.assign({}, ...o);
12 | }
13 |
14 | export function isString(o: any) {
15 | return Object.prototype.toString.call(o) === "[object String]";
16 | }
17 |
18 | export function isObject(o: any) {
19 | if (o === undefined || o === null) {
20 | return false;
21 | }
22 |
23 | return o.constructor === Object;
24 | }
25 |
26 | export function loadYamlObject(contents: string): ObjectType {
27 | const value = isBlank(contents) ? {} : yaml.load(contents);
28 | if (!isObject(value)) {
29 | throw new Error(`Expected yaml object but got ${contents}`);
30 | }
31 | return value as ObjectType;
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "strict": true,
6 | "esModuleInterop": true,
7 | "skipLibCheck": true,
8 | "forceConsistentCasingInFileNames": true,
9 | },
10 | "exclude": [
11 | "node_modules",
12 | "**/__tests__/**/*"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------