├── .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 | 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 `${text}`; 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 | --------------------------------------------------------------------------------