├── .gitignore ├── LICENSE ├── example ├── _imports │ ├── footer.html │ ├── head-description.html │ ├── head-title.html │ ├── head.html │ ├── header.html │ ├── links.md │ ├── markdown.md │ ├── options.md │ └── template.html ├── assets │ ├── css │ │ └── style.css │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-384x384.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── manifest.json │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg │ └── img │ │ ├── logo.svg │ │ └── og.jpg ├── index.html ├── links │ └── index.html ├── markdown │ └── index.html ├── options │ └── index.html └── slots │ └── index.html ├── index.js ├── netlify.toml ├── package.json ├── readme.md ├── src └── index.js └── tests └── index.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | public/ 3 | node_modules/ 4 | package-lock.json 5 | .env 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Trys Mudford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/_imports/footer.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /example/_imports/head-description.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /example/_imports/head-title.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <sergey-slot /> -------------------------------------------------------------------------------- /example/_imports/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | The little static site generator 37 | Default title 38 | -------------------------------------------------------------------------------- /example/_imports/header.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /example/_imports/links.md: -------------------------------------------------------------------------------- 1 | ## Why you might need links 2 | 3 | Let's take an example of a navigation template you might use on a site: 4 | 5 | ```html 6 | 11 | ``` 12 | 13 | If this template is saved as `_imports/navigation.html`, you can reuse the navigation on any page like so: 14 | 15 | ```html 16 | 17 | 18 | 19 | 20 | 21 | Our lil' website 22 | 23 | 24 |
25 | 26 |
27 |
28 |

Welcome!

29 |

Our page content.

30 |
31 | 32 | 33 | ``` 34 | 35 | So far, so good! However, on each page, we'd like this template to render a little differently. For example, on the homepage the navigation would render like so: 36 | 37 | ```html 38 | 43 | ``` 44 | 45 | Whereas on the about page, it should render like this: 46 | 47 | ```html 48 | 53 | ``` 54 | 55 | That's where `` comes in! 56 | 57 | #### Change navigation links to `` 58 | 59 | In the sample above, we can change our navigation template (`_imports/navigation.html`) to the following: 60 | 61 | ```html 62 | 67 | ``` 68 | 69 | Now when Sergey builds our site, it will add `class="active"` and `aria-current="page"` as appropriate on each page! 70 | 71 | #### Pass attributes through `` 72 | 73 | Any HTML attributes we pass to a `` will be passed through to the generated `` tag. Sergey will also combine any classes you've set with the active class: 74 | 75 | ```html 76 | Home 77 | ``` 78 | 79 | The above will be converted into: 80 | 81 | ```html 82 | Home 83 | ``` 84 | -------------------------------------------------------------------------------- /example/_imports/markdown.md: -------------------------------------------------------------------------------- 1 | #### Create a markdown file 2 | 3 | Write away to your hearts content, and save it in your `_imports` folder as something like `about.md` 4 | 5 | #### Import the markdown into your HTML page 6 | 7 | Sergey re-uses the `` tag with an `as="markdown"` attribute to denote the markdown format: 8 | 9 | ```html 10 | 11 | ``` 12 | -------------------------------------------------------------------------------- /example/_imports/options.md: -------------------------------------------------------------------------------- 1 | ## Options 2 | 3 | These options can be passed into the default `sergey` command or as entries in your `.env` file. 4 | 5 | --- 6 | 7 | **Arg:** `--watch` 8 | 9 | This runs Sergey in dev mode, and opens up a server at `http://localhost:8080`. Any changes you make to local files will trigger a recompile and be ready for you on page refresh. 10 | 11 | Top tip: set the **start** command to run `sergey`, and **dev** to run `sergey --watch`. Then you can run `npm start` and `npm run dev` respectively. 12 | 13 | --- 14 | 15 | **Arg:** `--root=` 16 | **Env:** `SERGEY_ROOT` 17 | 18 | By default, Sergey runs in the same directory as your `package.json` file. You can override that with this command. Be sure to start it with a dot, and end it with a slash. 19 | 20 | --- 21 | 22 | **Arg:** `--output=` 23 | **Env:** `SERGEY_OUTPUT` 24 | 25 | A `public` folder will be created to hold all the built files, unless you specify otherwise with this option. 26 | 27 | --- 28 | 29 | **Arg:** `--imports=` 30 | **Env:** `SERGEY_IMPORTS` 31 | 32 | Sergey uses an `_imports` folder by default, but this argument lets you change that. 33 | 34 | --- 35 | 36 | **Arg:** `--content=` 37 | **Env:** `SERGEY_CONTENT` 38 | 39 | Markdown files should be stored in a `_imports` folder, but you can override that. 40 | 41 | --- 42 | 43 | **Arg:** `--exclude=` 44 | **Env:** `SERGEY_EXCLUDE` 45 | 46 | When in dev mode, Sergey watches for file changes, but ignores common folders like `node_modules`. It also ignores everything in your `.gitignore` file. You can add to that list with this argument, in a comma-separated format. 47 | 48 | --- 49 | 50 | **Arg:** `--port=` 51 | **Env:** `SERGEY_PORT` 52 | 53 | Override the default port of 8080 with this option. 54 | -------------------------------------------------------------------------------- /example/_imports/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/assets/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | blockquote, 6 | pre, 7 | ol, 8 | ul, 9 | figure { 10 | padding: 0; 11 | margin: 0; 12 | } 13 | 14 | img { 15 | max-width: 100%; 16 | display: block; 17 | height: auto; 18 | border: none; 19 | } 20 | 21 | article, 22 | aside, 23 | figure, 24 | footer, 25 | header, 26 | aside, 27 | main, 28 | nav { 29 | display: block; 30 | } 31 | 32 | *, 33 | *:before, 34 | *:after { 35 | box-sizing: border-box; 36 | } 37 | 38 | iframe { 39 | border: none; 40 | } 41 | 42 | /* Settings */ 43 | 44 | :root { 45 | --color-copy: #16355f; 46 | --color-light: #fafafa; 47 | } 48 | 49 | /* Typography */ 50 | 51 | body { 52 | -moz-osx-font-smoothing: grayscale; 53 | -webkit-font-smoothing: antialiased; 54 | color: var(--color-copy); 55 | background-color: var(--color-light); 56 | font: 400 16px/1.625 'SF Pro Text', -apple-system, BlinkMacSystemFont, 57 | Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, 58 | Segoe UI Emoji, Segoe UI Symbol; 59 | font-size: calc(16px + (24 - 16) * (100vw - 320px) / (750 - 320)); 60 | } 61 | 62 | @media (min-width: 750px) { 63 | body { 64 | font-size: 25px; 65 | } 66 | } 67 | 68 | a { 69 | color: inherit; 70 | } 71 | 72 | [aria-current] { 73 | font-weight: 600; 74 | text-decoration: none; 75 | } 76 | 77 | .center { 78 | text-align: center; 79 | } 80 | 81 | h1, 82 | h2, 83 | h3, 84 | h4, 85 | ul, 86 | ol, 87 | p { 88 | margin: 0; 89 | } 90 | 91 | h1, 92 | h2, 93 | h3 { 94 | line-height: 1.2; 95 | } 96 | 97 | h4 { 98 | font-weight: 400; 99 | font-size: 1em; 100 | } 101 | 102 | .content * + * { 103 | margin-top: 1.5rem; 104 | } 105 | 106 | .content * + h2 { 107 | margin-top: 2.5rem; 108 | } 109 | 110 | .content, 111 | .content h2 { 112 | counter-reset: contentCounter; 113 | } 114 | 115 | .content [counter], 116 | .content h4 { 117 | position: relative; 118 | counter-increment: contentCounter; 119 | } 120 | 121 | .content [counter]:before, 122 | .content h4:before { 123 | content: counter(contentCounter) '. '; 124 | font-weight: 600; 125 | } 126 | 127 | @media (min-width: 880px) { 128 | .content [counter]:before, 129 | .content h4:before { 130 | position: absolute; 131 | margin-right: 1rem; 132 | right: 100%; 133 | top: 0; 134 | } 135 | } 136 | 137 | code { 138 | font-size: 0.8em; 139 | white-space: nowrap; 140 | background: rgba(0, 0, 0, 0.05); 141 | padding: 0.1em; 142 | } 143 | 144 | pre { 145 | line-height: 1; 146 | background: rgba(0, 0, 0, 0.05); 147 | padding: 0.5em; 148 | max-width: 100%; 149 | overflow-x: auto; 150 | -webkit-overflow-scroll: touch; 151 | } 152 | 153 | pre code { 154 | background: transparent; 155 | white-space: inherit; 156 | } 157 | 158 | .content hr { 159 | margin: 2em 0; 160 | border: none; 161 | border-top: 1px solid rgba(0, 0, 0, 0.1); 162 | } 163 | 164 | /* Grid */ 165 | 166 | .wrapper { 167 | max-width: 800px; 168 | margin: 0 auto; 169 | padding-left: 20px; 170 | padding-right: 20px; 171 | } 172 | 173 | .insulate { 174 | padding-top: 2em; 175 | padding-bottom: 2em; 176 | } 177 | 178 | /* Header */ 179 | 180 | [role='banner'] { 181 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); 182 | background: #fff 183 | url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iNzIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTAgMGgxMkw2IDEyIDAgMHptMTggMzZoMTJsLTYgMTItNi0xMnoiIGZpbGw9IiNFQkY0RjEiIGZpbGwtcnVsZT0ibm9uemVybyIgZmlsbC1vcGFjaXR5PSIuNSIvPjwvc3ZnPg==); 184 | } 185 | 186 | [role='banner'] .logo { 187 | margin: 0 auto; 188 | max-width: 75%; 189 | } 190 | 191 | [role='banner'] nav a { 192 | margin: 0 0.3em; 193 | font-size: 0.8em; 194 | } 195 | 196 | /* Footer */ 197 | 198 | [role='contentinfo'] { 199 | padding-top: 1em; 200 | padding-bottom: 1em; 201 | display: flex; 202 | justify-content: space-between; 203 | align-items: center; 204 | border-top: 1px solid rgba(0, 0, 0, 0.1); 205 | } 206 | 207 | [role='contentinfo'] .deploy { 208 | max-width: 40%; 209 | } 210 | -------------------------------------------------------------------------------- /example/assets/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /example/assets/icons/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/android-chrome-384x384.png -------------------------------------------------------------------------------- /example/assets/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /example/assets/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /example/assets/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/favicon-16x16.png -------------------------------------------------------------------------------- /example/assets/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/favicon-32x32.png -------------------------------------------------------------------------------- /example/assets/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/favicon.ico -------------------------------------------------------------------------------- /example/assets/icons/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sergey", 3 | "short_name": "Sergey", 4 | "icons": [ 5 | { 6 | "src": "/assets/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/assets/icons/android-chrome-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#16355f", 17 | "background_color": "#16355f", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /example/assets/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/icons/mstile-150x150.png -------------------------------------------------------------------------------- /example/assets/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /example/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /example/assets/img/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trys/sergey/40730dfea7bb36ce21158639107919df093aa958/example/assets/img/og.jpg -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sergey | the little static site generator 6 | 7 | 8 | 9 | 10 |

Sergey is a tiny lil’ static site generator. It’s a progressive tool designed to sit atop your already brilliant 11 | HTML. In essence, Sergey is HTML + partials with slots (named and default!) thrown in for good measure.

12 |

If you’ve ever had to make a change to every header on a totally static website, you’ll know how cumbersome and 13 | error-prone it is to copy and paste the changes through all the files. That’s where Sergey comes in. Sergey lets you 14 | move that header into a single, importable file, and helps you include it everywhere you need it.

15 |

Sergey is still in it's infancy, so please report any bugs and suggestions here!

16 |
17 | 18 |
19 |
20 |

Quick start

21 |

Deploying to Netlify is the quickest way to get started with Sergey. Give the button below a click, and an example website will be instantly deployed for you to play around with! It'll look a bit like this.

22 |

23 | 24 | Deploy to Netlify 25 | 26 |

27 | 28 |
29 | 30 |

This is the tldr; for those au fait with npm packages. There's step-by-step walkthrough a little further down the page.

31 |
 32 | 
 33 | $ npm install sergey
 34 | 
 35 | 
36 | 37 |

Add sergey to your start and sergey --watch to your dev scripts in package.json.

38 |

Create an _imports folder, and place a header.html file inside.

39 |

Import it into your other files with <sergey-import src="header" />

40 |
 41 | 
 42 | $ npm run dev
 43 | 
 44 | 
45 |

Sergey will start a dev server and watch for any changes.

46 |
 47 | 
 48 | $ npm start
 49 | 
 50 | 
51 |

Sergey will create a public folder ready to deploy.

52 | 53 |
54 | 55 |

What is Sergey

56 |

Sergey is a static site generator, albeit, a very basic one. That's intentional. There are some incredible SSG's out there (I'm a big fan of Hugo, Nuxt and 11ty).

57 |

They're all fantastic in their own ways, but also pretty large and in charge. Often when I'm prototyping a website, I don't know if I'll need half the features they offer, nor do I want to spend hours configuring them or reading docs. I just want to get my hands dirty with HTML.

58 |

Sergey is a no-configuration SSG that will render your HTML, include partials, and render out slots. It'll compile the files and copy your assets into a public folder ready to be Deployed.

59 |

Will it do more in the future? Who knows! Perhaps, but if you need to do more, that's probably a good time to look at some of the illustrious SSG's listed above. Feel free to use Sergey as a springboard, a prototyping tool, or a full-blown production generator. It's up to you!

60 | 61 |
62 | 63 |

The Hello, World example

64 |

Our starting point is an index.html file.

65 | 66 | 67 | 68 |

This is lovely, clean HTML. But it becomes problematic when we add a few more pages and need to update the header on 69 | each page.

70 | 71 |

Let’s move the header into it’s own file: _imports/header.html

72 | 73 | 74 | 75 |

Then we can import it in to our original file with the 76 | <sergey-import src=“header” /> tag.

77 | 78 | 79 | 80 |

If you’ve already got a package.json file, feel free to ignore this point!

81 | 82 |

You’ll need to have Node.js installed to use Sergey. Once that’s done, open terminal/command line and cd into your 83 | website directory. Then, run the following command: npm init -y

84 | 85 |

It’s time to install Sergey! Run the command: 86 | npm install sergey

87 | 88 |

Now we need to add the build and dev commands to the scripts section of the package.json file. All in all, it should 89 | look a bit like this:

90 | 91 | 92 | 93 |

Wonderful! Let’s run Sergey with the command: npm run start. If it’s all gone well, you should have a new public folder 94 | in your website directory! It should include an index.html file with the original header in place of the 95 | <sergey-import />.

96 | 97 |

This public folder is the one to upload to your web server. If you’re deploying via Git (say to Netlify), you can add 98 | public/ to your .gitignore file.

99 | 100 |

When you're ready to create a new page, create new folder (say 'about') and place copy of the index.html file in there. That'll become available at yourdomain.com/about/

101 |
102 |
103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /example/links/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Links | Sergey | the little static site generator 7 | 8 | 9 | 10 | 11 | 12 |

Links are a practical way to show your users where they are in your navigation. They're completely opt-in, so if you prefer to use classic HTML links, go for it!

13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /example/markdown/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Markdown | Sergey | the little static site generator 6 | 7 | 8 | 9 | 10 | 11 |

Knowledge of markdown is often a requirement for static site generators, but Sergey treats markdown as an opt-in feature. So if you'd like to format some or all of your content in markdown, here's how to do it:

12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Options | Sergey | the little static site generator 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 |
17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/slots/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Slots | Sergey | the little static site generator 7 | 8 | 9 | 10 | 11 | 12 |

Slots are a very useful way to re-use components. Let's explore them a little further...

13 |
14 | 15 |
16 |
17 |

Getting started with slots

18 |

Any _imports file can have a special tag inside called a <sergey-slot />. This slot provides a neat way to inject content within the component.

19 | 20 |

Let's take a <head> of the page example. Here's our markup:

21 | 22 | 23 | 24 |

Our SEO senses tell us that a page title per page would be a good thing. But now we're using Sergey, all of the <title> tags are stored away in the one file.

25 | 26 |

To kick off, we can swap in a <sergey-slot /> tag:

27 | 28 | 29 | 30 |

To fill that slot with content, we alter our original <sergey-import /> tag in a very HTML-y way:

31 | 32 | 33 | 34 |

That's it! If you pass in a title (or any other tags), it'll render it in place of the slot.

35 | 36 |
37 | 38 |

Default slots

39 | 40 |

If you want some default content to render when nothing's passed into the slot, expand the <sergey-slot /> tag to include that content:

41 | 42 | 43 | 44 |
45 | 46 |

Named slots

47 | 48 |

The humble slot is great, but sometimes it's handy to pass multiple bits of data into a component. This is where named slots come in.

49 | 50 |

Note, named slots are a more advanced feature, so don't feel you have to rush to use them, only break them out when you need to. They are comprised of two parts:

51 | 52 |
    53 |
  • <sergey-template name="slotName" />
  • 54 |
  • <sergey-slot name="slotName" />
  • 55 |
56 | 57 |

You can kinda think of these as variables. The template is the 'variable definition' and is used within a sergey-import tag. It's where you define the content that'll get injected into the slot.

58 | 59 |

The slot with the name attribute tells Sergey where you'd like to put the content. Top tip, you can have multiple slots per template! This is really handy for meta titles and descriptions.

60 | 61 |

Here's a blog preview example. We've defined two slots for the page title and author:

62 | 63 | 64 | 65 |

To fill them in, we pass in two templates wherever we import the head.html file:

66 | 67 | 68 | 69 |
70 | 71 |

Page templates with slots

72 | 73 |

One neat thing you can do with slots is build page templates!

74 | 75 |

Let's create _imports/template.html file:

76 | 77 | 78 | 79 |

Note that _imports can include other imports! This template pulls in a head (with a slot), header and a footer. It also leaves space for a slot.

80 | 81 |

In our index.html file, instead of calling in all those imports individually, we can simply wrap our page-specific content in one <sergey-import /> and it'll get squirted into the template. Look how tiny our page has become!

82 | 83 | 84 | 85 |

To create a new page, all you need to do is copy/paste those three lines into a new file, and start writing!

86 | 87 |

If we really wanted to supercharge this, we could use named slots to control various parts of the template! The sky's the limit!

88 | 89 |
90 |
91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { sergeyRuntime } = require('./src'); 3 | sergeyRuntime(); 4 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run start" 3 | publish = "example/public" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sergey", 3 | "version": "0.0.13", 4 | "description": "The little static site generator", 5 | "main": "index.js", 6 | "bin": { 7 | "sergey": "./index.js" 8 | }, 9 | "scripts": { 10 | "start": "node index.js --root=./example/", 11 | "dev": "node index.js --watch --root=./example/", 12 | "test": "jest --root=./example/" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/trys/sergey.git" 17 | }, 18 | "keywords": [], 19 | "author": "Trys Mudford (https://www.trysmudford.com)", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/trys/sergey/issues" 23 | }, 24 | "homepage": "https://github.com/trys/sergey#readme", 25 | "dependencies": { 26 | "chokidar": "^2.1.5", 27 | "connect": "^3.6.6", 28 | "dotenv": "^7.0.0", 29 | "marked": "^0.6.2", 30 | "serve-static": "^1.13.2" 31 | }, 32 | "devDependencies": { 33 | "jest": "^24.7.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Sergey 2 | 3 | ## The little static site generator 4 | 5 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/trys/sergey-netlify) 6 | 7 | Sergey is a tiny lil’ static site generator. It’s a progressive tool designed to site atop your already brilliant HTML. In essence, Sergey is HTML + partials with slots thrown in for good measure. 8 | 9 | If you’ve ever had to make a change to every header on a totally static website, you’ll know how cumbersome and error-prone it is to copy and paste the changes through all the files. That’s where Sergey comes in. Sergey lets you move that header into a single, importable file, and helps you include it everywhere you need it. 10 | 11 | - [Read the getting started guide](https://sergey.cool/#get-started) 12 | - [Slots explanation](https://sergey.cool/slots/) 13 | - [Command line options](https://sergey.cool/options/) 14 | 15 | ```bash 16 | $ npm install sergey 17 | 18 | # Build the site 19 | $ sergey 20 | 21 | # Run Sergey in dev mode 22 | $ sergey --watch 23 | ``` 24 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | const { performance } = require('perf_hooks'); 4 | const marked = require('marked'); 5 | require('dotenv').config(); 6 | 7 | /** 8 | * Environment varibales 9 | */ 10 | const getEnv = (argKey, envKey) => { 11 | return ( 12 | process.env[envKey] || 13 | (process.argv.find(x => x.startsWith(argKey)) || '').replace(argKey, '') 14 | ); 15 | }; 16 | const isWatching = process.argv.includes('--watch'); 17 | 18 | const ROOT = getEnv('--root=', 'SERGEY_ROOT') || './'; 19 | const PORT = Number(getEnv('--port=', 'SERGEY_PORT')) || 8080; 20 | 21 | const IMPORTS_LOCAL = getEnv('--imports=', 'SERGEY_IMPORTS') || '_imports'; 22 | const IMPORTS = `${ROOT}${IMPORTS_LOCAL}/`; 23 | 24 | const CONTENT_LOCAL = getEnv('--content=', 'SERGEY_CONTENT') || '_imports'; 25 | const CONTENT = `${ROOT}${CONTENT_LOCAL}/`; 26 | 27 | const OUTPUT_LOCAL = getEnv('--output=', 'SERGEY_OUTPUT') || 'public'; 28 | const OUTPUT = `${ROOT}${OUTPUT_LOCAL}/`; 29 | 30 | const ACTIVE_CLASS = 31 | getEnv('--active-class=', 'SERGEY_ACTIVE_CLASS') || 'active'; 32 | 33 | const EXCLUDE = (getEnv('--exclude=', 'SERGEY_EXCLUDE') || '') 34 | .split(',') 35 | .map(x => x.trim()) 36 | .filter(Boolean); 37 | 38 | const VERBOSE = false; 39 | const cachedImports = {}; 40 | 41 | const excludedFolders = [ 42 | '.git', 43 | '.DS_Store', 44 | '.prettierrc', 45 | 'node_modules', 46 | 'package.json', 47 | 'package-lock.json', 48 | IMPORTS_LOCAL, 49 | OUTPUT_LOCAL, 50 | ...EXCLUDE 51 | ]; 52 | 53 | const patterns = { 54 | whitespace: /^\s+|\s+$/g, 55 | templates: /(.*?)<\/sergey-template>/gms, 56 | complexNamedSlots: /(.*?)<\/sergey-slot>/gms, 57 | simpleNamedSlots: //gm, 58 | complexDefaultSlots: /(.*?)<\/sergey-slot>/gms, 59 | simpleDefaultSlots: //gm, 60 | complexImports: /(.*?)<\/sergey-import>/gms, 61 | simpleImports: //gm, 62 | links: /(.*?)<\/sergey-link>/gms 63 | }; 64 | 65 | /** 66 | * FS utils 67 | */ 68 | const copyFile = (src, dest) => { 69 | return new Promise((resolve, reject) => { 70 | fs.copyFile(src, dest, err => { 71 | if (err) { 72 | return reject(err); 73 | } else { 74 | VERBOSE && console.log(`Copied ${src}`); 75 | resolve(); 76 | } 77 | }); 78 | }); 79 | }; 80 | 81 | const createFolder = path => { 82 | return new Promise((resolve, reject) => { 83 | fs.readdir(path, (err, data) => { 84 | if (err) { 85 | fs.mkdir(path, (err, data) => { 86 | return err ? reject(`Couldn't create folder: ${path}`) : resolve(); 87 | }); 88 | } else { 89 | return resolve(); 90 | } 91 | }); 92 | }); 93 | }; 94 | 95 | const readDir = path => { 96 | return new Promise((resolve, reject) => { 97 | fs.readdir(path, (err, data) => (err ? reject(err) : resolve(data))); 98 | }); 99 | }; 100 | 101 | const readFile = path => { 102 | return new Promise((resolve, reject) => { 103 | fs.readFile(path, (err, data) => 104 | err ? reject(err) : resolve(data.toString()) 105 | ); 106 | }); 107 | }; 108 | 109 | const writeFile = (path, body) => { 110 | return new Promise((resolve, reject) => { 111 | fs.writeFile(path, body, err => { 112 | if (err) { 113 | return reject(err); 114 | } 115 | 116 | VERBOSE && console.log(`Saved ${path}`); 117 | return resolve(); 118 | }); 119 | }); 120 | }; 121 | 122 | const clearOutputFolder = async () => { 123 | const deleteFolder = path => { 124 | if (fs.existsSync(path)) { 125 | fs.readdirSync(path).forEach(function(file, index) { 126 | const newPath = path + '/' + file; 127 | if (fs.lstatSync(newPath).isDirectory()) { 128 | deleteFolder(newPath); 129 | } else { 130 | fs.unlinkSync(newPath); 131 | } 132 | }); 133 | fs.rmdirSync(path); 134 | } 135 | }; 136 | 137 | return deleteFolder(OUTPUT); 138 | }; 139 | 140 | const getAllFiles = (path, filter, exclude = false) => { 141 | path = path.endsWith('/') ? path.substring(0, path.length - 1) : path; 142 | 143 | const files = []; 144 | const filesToIgnore = [...excludedFolders]; 145 | if (!filter) { 146 | filter = () => true; 147 | } 148 | 149 | if (exclude) { 150 | const importIndex = filesToIgnore.indexOf(IMPORTS_LOCAL); 151 | if (importIndex !== -1) filesToIgnore.splice(importIndex, 1); 152 | 153 | const contentIndex = filesToIgnore.indexOf(CONTENT_LOCAL); 154 | if (contentIndex !== -1) filesToIgnore.splice(contentIndex, 1); 155 | } 156 | 157 | if (fs.existsSync(path)) { 158 | fs.readdirSync(path).forEach((file, index) => { 159 | if (filesToIgnore.find(x => file.startsWith(x))) { 160 | return; 161 | } 162 | 163 | const newPath = path + '/' + file; 164 | if (fs.lstatSync(newPath).isDirectory()) { 165 | files.push(...getAllFiles(newPath, filter, exclude)); 166 | } else { 167 | if (!filter(file)) { 168 | return; 169 | } 170 | 171 | files.push(newPath); 172 | } 173 | }); 174 | } 175 | 176 | return files; 177 | }; 178 | 179 | const getFilesToWatch = path => { 180 | return getAllFiles(path, '', true); 181 | }; 182 | 183 | /** 184 | * Helpers 185 | */ 186 | const formatContent = x => x.replace(patterns.whitespace, ''); 187 | const getKey = (key, ext = '.html', folder = '') => { 188 | const file = key.endsWith(ext) ? key : `${key}${ext}`; 189 | return `${folder}${file}`; 190 | }; 191 | const hasImports = x => x.includes(' x.includes(' { 194 | if (!excludedFolders.includes(name)) { 195 | excludedFolders.push(name); 196 | } 197 | }; 198 | const cleanPath = path => path.replace('index.html', '').split('#')[0]; 199 | const isCurrentPage = (ref, path) => path && cleanPath(path) === cleanPath(ref); 200 | const isParentPage = (ref, path) => 201 | path && cleanPath(path).startsWith(cleanPath(ref)); 202 | 203 | /** 204 | * #business logic 205 | */ 206 | const prepareImports = async folder => { 207 | const fileNames = await getAllFiles(folder); 208 | const bodies = await Promise.all(fileNames.map(readFile)); 209 | fileNames.forEach((path, i) => primeImport(path, bodies[i])); 210 | }; 211 | 212 | const primeImport = (path, body) => { 213 | cachedImports[path] = body; 214 | }; 215 | 216 | const getSlots = content => { 217 | // Extract templates first 218 | const slots = { 219 | default: formatContent(content) || '' 220 | }; 221 | 222 | // Search content for templates 223 | while ((m = patterns.templates.exec(content)) !== null) { 224 | if (m.index === patterns.templates.lastIndex) { 225 | patterns.templates.lastIndex++; 226 | } 227 | 228 | const [find, name, data] = m; 229 | if (name !== 'default') { 230 | // Remove it from the default content 231 | slots.default = slots.default.replace(find, ''); 232 | } 233 | 234 | // Add it as a named slot 235 | slots[name] = formatContent(data); 236 | } 237 | 238 | slots.default = formatContent(slots.default); 239 | 240 | return slots; 241 | }; 242 | 243 | const compileSlots = (body, slots) => { 244 | let m; 245 | let copy; 246 | 247 | // Complex named slots 248 | copy = body; 249 | while ((m = patterns.complexNamedSlots.exec(body)) !== null) { 250 | if (m.index === patterns.complexNamedSlots.lastIndex) { 251 | patterns.complexNamedSlots.lastIndex++; 252 | } 253 | 254 | const [find, name, fallback] = m; 255 | copy = copy.replace(find, slots[name] || fallback || ''); 256 | } 257 | body = copy; 258 | 259 | // Simple named slots 260 | while ((m = patterns.simpleNamedSlots.exec(body)) !== null) { 261 | if (m.index === patterns.simpleNamedSlots.lastIndex) { 262 | patterns.simpleNamedSlots.lastIndex++; 263 | } 264 | 265 | const [find, name] = m; 266 | copy = copy.replace(find, slots[name] || ''); 267 | } 268 | body = copy; 269 | 270 | // Complex Default slots 271 | while ((m = patterns.complexDefaultSlots.exec(body)) !== null) { 272 | if (m.index === patterns.complexDefaultSlots.lastIndex) { 273 | patterns.complexDefaultSlots.lastIndex++; 274 | } 275 | 276 | const [find, fallback] = m; 277 | copy = copy.replace(find, slots.default || fallback || ''); 278 | } 279 | body = copy; 280 | 281 | // Simple default slots 282 | body = body.replace(patterns.simpleDefaultSlots, slots.default); 283 | 284 | return body; 285 | }; 286 | 287 | const compileImport = (body, pattern) => { 288 | let m; 289 | // Simple imports 290 | while ((m = pattern.exec(body)) !== null) { 291 | if (m.index === pattern.lastIndex) { 292 | pattern.lastIndex++; 293 | } 294 | 295 | let [find, key, htmlAs = '', content = ''] = m; 296 | let replace = ''; 297 | 298 | if (htmlAs === 'markdown') { 299 | replace = formatContent( 300 | marked(cachedImports[getKey(key, '.md', CONTENT)] || '') 301 | ); 302 | } else { 303 | replace = cachedImports[getKey(key, '.html', IMPORTS)] || ''; 304 | } 305 | 306 | const slots = getSlots(content); 307 | 308 | // Recurse 309 | replace = compileTemplate(replace, slots); 310 | body = body.replace(find, replace); 311 | } 312 | 313 | return body; 314 | }; 315 | 316 | const compileTemplate = (body, slots = { default: '' }) => { 317 | body = compileSlots(body, slots); 318 | 319 | if (!hasImports(body)) { 320 | return body; 321 | } 322 | 323 | body = compileImport(body, patterns.simpleImports); 324 | body = compileImport(body, patterns.complexImports); 325 | 326 | return body; 327 | }; 328 | 329 | const compileLinks = (body, path) => { 330 | let m; 331 | let copy; 332 | 333 | if (!hasLinks(body)) { 334 | return body; 335 | } 336 | 337 | copy = body; 338 | while ((m = patterns.links.exec(body)) !== null) { 339 | if (m.index === patterns.links.lastIndex) { 340 | patterns.links.lastIndex++; 341 | } 342 | 343 | let [find, attr1 = '', to, attr2 = '', content] = m; 344 | let replace = ''; 345 | let attributes = [`href="${to}"`, attr1, attr2] 346 | .map(x => x.trim()) 347 | .filter(Boolean) 348 | .join(' '); 349 | 350 | const isCurrent = isCurrentPage(to, path); 351 | if (isCurrent || isParentPage(to, path)) { 352 | if (attributes.includes('class="')) { 353 | attributes = attributes.replace('class="', `class="${ACTIVE_CLASS} `); 354 | } else { 355 | attributes += ` class="${ACTIVE_CLASS}"`; 356 | } 357 | 358 | if (isCurrent) { 359 | attributes += ' aria-current="page"'; 360 | } 361 | } 362 | 363 | replace = `${content}`; 364 | copy = copy.replace(find, replace); 365 | } 366 | body = copy; 367 | 368 | return body; 369 | }; 370 | 371 | const compileFolder = async (localFolder, localPublicFolder) => { 372 | const fullFolderPath = `${ROOT}${localFolder}`; 373 | const fullPublicPath = `${ROOT}${localPublicFolder}`; 374 | 375 | if (localPublicFolder) { 376 | await createFolder(fullPublicPath); 377 | } 378 | 379 | return new Promise((resolve, reject) => { 380 | fs.readdir(fullFolderPath, async (err, files) => { 381 | if (err) { 382 | return reject(`Folder: ${fullFolderPath} doesn't exist`); 383 | } 384 | 385 | Promise.all( 386 | files 387 | .filter(x => { 388 | return !excludedFolders.find(y => x.startsWith(y)); 389 | }) 390 | .map(async localFilePath => { 391 | const fullFilePath = `${fullFolderPath}${localFilePath}`; 392 | const fullPublicFilePath = `${fullPublicPath}${localFilePath}`; 393 | const fullLocalFilePath = `/${localFolder}${localFilePath}`; 394 | 395 | if (localFilePath.endsWith('.html')) { 396 | return readFile(fullFilePath) 397 | .then(compileTemplate) 398 | .then(body => compileLinks(body, fullLocalFilePath)) 399 | .then(body => writeFile(fullPublicFilePath, body)); 400 | } 401 | 402 | return new Promise((resolve, reject) => { 403 | fs.stat(fullFilePath, async (err, stat) => { 404 | if (err) { 405 | return reject(err); 406 | } 407 | 408 | if (stat && stat.isDirectory()) { 409 | await compileFolder( 410 | `${localFolder}${localFilePath}/`, 411 | `${OUTPUT_LOCAL}/${localFolder}${localFilePath}/` 412 | ); 413 | } else { 414 | await copyFile(fullFilePath, fullPublicFilePath); 415 | } 416 | return resolve(); 417 | }); 418 | }); 419 | }) 420 | ) 421 | .then(resolve) 422 | .catch(reject); 423 | }); 424 | }); 425 | }; 426 | 427 | const compileFiles = async () => { 428 | try { 429 | await readDir(IMPORTS); 430 | } catch (e) { 431 | console.error(`No ${IMPORTS} folder found`); 432 | return; 433 | } 434 | 435 | try { 436 | const start = performance.now(); 437 | 438 | await clearOutputFolder(); 439 | await prepareImports(IMPORTS); 440 | 441 | if (IMPORTS !== CONTENT) { 442 | try { 443 | await readDir(CONTENT); 444 | await prepareImports(CONTENT); 445 | } catch (e) {} 446 | } 447 | 448 | await compileFolder('', `${OUTPUT_LOCAL}/`); 449 | 450 | const end = performance.now(); 451 | 452 | console.log(`Compiled in ${Math.ceil(end - start)}ms`); 453 | } catch (e) { 454 | console.log(e); 455 | } 456 | }; 457 | 458 | const excludeGitIgnoreContents = async () => { 459 | try { 460 | const ignore = await readFile('./.gitignore'); 461 | const exclusions = ignore 462 | .split('\n') 463 | .map(x => (x.endsWith('/') ? x.substring(0, x.length - 1) : x)) 464 | .map(x => (x.startsWith('/') ? x.substring(1, x.length) : x)) 465 | .filter(Boolean) 466 | .map(primeExcludedFiles); 467 | } catch (e) {} 468 | }; 469 | 470 | const sergeyRuntime = async () => { 471 | if (!OUTPUT.startsWith('./')) { 472 | console.error('DANGER! Make sure you start the root with a ./'); 473 | return; 474 | } 475 | 476 | if (!ROOT.endsWith('/')) { 477 | console.error('Make sure you end the root with a /'); 478 | return; 479 | } 480 | 481 | await excludeGitIgnoreContents(); 482 | await compileFiles(); 483 | 484 | if (isWatching) { 485 | const chokidar = require('chokidar'); 486 | const connect = require('connect'); 487 | const serveStatic = require('serve-static'); 488 | 489 | const watchRoot = ROOT.endsWith('/') 490 | ? ROOT.substring(0, ROOT.length - 1) 491 | : ROOT; 492 | let ignored = (OUTPUT.endsWith('/') 493 | ? OUTPUT.substring(0, OUTPUT.length - 1) 494 | : OUTPUT 495 | ).replace('./', ''); 496 | 497 | const task = async () => await compileFiles(); 498 | 499 | const watcher = chokidar.watch(watchRoot, { ignored, ignoreInitial: true }); 500 | watcher.on('change', task); 501 | watcher.on('add', task); 502 | watcher.on('unlink', task); 503 | 504 | connect() 505 | .use(serveStatic(OUTPUT)) 506 | .listen(PORT, function() { 507 | console.log(`Sergey running on http://localhost:${PORT}`); 508 | }); 509 | } 510 | }; 511 | 512 | module.exports = { 513 | sergeyRuntime, 514 | compileTemplate, 515 | compileLinks, 516 | primeImport, 517 | CONTENT, 518 | IMPORTS, 519 | ACTIVE_CLASS 520 | }; 521 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | compileTemplate, 3 | compileLinks, 4 | primeImport, 5 | IMPORTS, 6 | ACTIVE_CLASS 7 | } = require('../src'); 8 | 9 | const wrapper = (x = '') => ` 10 | 11 | ${x} 12 | 13 | `; 14 | 15 | const header = (x = '') => `
16 | Home 17 | ${x} 18 |
`; 19 | 20 | const footer = () => `
21 | © 2019 22 |
`; 23 | 24 | const testImport = file => `${IMPORTS}${file}`; 25 | 26 | describe('Slot compilation', () => { 27 | test('Zero compilation', () => { 28 | const input = wrapper('

Test

'); 29 | 30 | const output = compileTemplate(input); 31 | 32 | expect(output).toBe(input); 33 | }); 34 | 35 | test('Basic slot filling', () => { 36 | const content = 'Content'; 37 | 38 | const input = wrapper(''); 39 | const desiredOutput = wrapper(content); 40 | 41 | const output = compileTemplate(input, { default: content }); 42 | 43 | expect(output).toBe(desiredOutput); 44 | }); 45 | 46 | test(' tag', () => { 47 | const content = 'Content'; 48 | 49 | const input = wrapper(''); 50 | const desiredOutput = wrapper(content); 51 | 52 | const output = compileTemplate(input, { default: content }); 53 | 54 | expect(output).toBe(desiredOutput); 55 | }); 56 | 57 | test(' tag', () => { 58 | const content = 'Content'; 59 | 60 | const input = wrapper(''); 61 | const desiredOutput = wrapper(content); 62 | 63 | const output = compileTemplate(input, { default: content }); 64 | 65 | expect(output).toBe(desiredOutput); 66 | }); 67 | 68 | test('Basic slot with whitespace', () => { 69 | const content = 'Content\nNewline'; 70 | 71 | const input = wrapper(''); 72 | const desiredOutput = wrapper(content); 73 | 74 | const output = compileTemplate(input, { default: content }); 75 | 76 | expect(output).toBe(desiredOutput); 77 | }); 78 | 79 | test('Basic slot with HTML', () => { 80 | const content = '

Paragraph

'; 81 | 82 | const input = wrapper(''); 83 | const desiredOutput = wrapper(content); 84 | 85 | const output = compileTemplate(input, { default: content }); 86 | 87 | expect(output).toBe(desiredOutput); 88 | }); 89 | 90 | test('Default slot content', () => { 91 | const defaultContent = 'Default content'; 92 | 93 | const input = wrapper(`${defaultContent}`); 94 | const desiredOutput = wrapper(defaultContent); 95 | 96 | const output = compileTemplate(input); 97 | 98 | expect(output).toBe(desiredOutput); 99 | }); 100 | 101 | test('Named slot', () => { 102 | const namedContent = 'Named content'; 103 | 104 | const input = wrapper(``); 105 | const desiredOutput = wrapper(namedContent); 106 | 107 | const output = compileTemplate(input, { named: namedContent }); 108 | 109 | expect(output).toBe(desiredOutput); 110 | }); 111 | 112 | test('Named slot with underscores', () => { 113 | const namedContent = 'Named content'; 114 | 115 | const input = wrapper(``); 116 | const desiredOutput = wrapper(namedContent); 117 | 118 | const output = compileTemplate(input, { named_slot: namedContent }); 119 | 120 | expect(output).toBe(desiredOutput); 121 | }); 122 | 123 | test('Named slot with spaceless tag', () => { 124 | const namedContent = 'Named content'; 125 | 126 | const input = wrapper(``); 127 | const desiredOutput = wrapper(namedContent); 128 | 129 | const output = compileTemplate(input, { named: namedContent }); 130 | 131 | expect(output).toBe(desiredOutput); 132 | }); 133 | 134 | test('Named slot with full tag', () => { 135 | const namedContent = 'Named content'; 136 | 137 | const input = wrapper(``); 138 | const desiredOutput = wrapper(namedContent); 139 | 140 | const output = compileTemplate(input, { named: namedContent }); 141 | 142 | expect(output).toBe(desiredOutput); 143 | }); 144 | 145 | test('Named slot with default content tag and named content', () => { 146 | const namedContent = 'Named content'; 147 | 148 | const input = wrapper( 149 | `Default content` 150 | ); 151 | const desiredOutput = wrapper(namedContent); 152 | 153 | const output = compileTemplate(input, { named: namedContent }); 154 | 155 | expect(output).toBe(desiredOutput); 156 | }); 157 | 158 | test('Named slot with default content tag and named content', () => { 159 | const defaultContent = 'Default content'; 160 | 161 | const input = wrapper( 162 | `${defaultContent}` 163 | ); 164 | const desiredOutput = wrapper(defaultContent); 165 | 166 | const output = compileTemplate(input, { 167 | named: '' 168 | }); 169 | 170 | expect(output).toBe(desiredOutput); 171 | }); 172 | }); 173 | 174 | describe('Import compilation', () => { 175 | test('A basic import', () => { 176 | primeImport(testImport('header.html'), header()); 177 | 178 | const desiredOutput = header(); 179 | const output = compileTemplate(''); 180 | 181 | expect(output).toBe(desiredOutput); 182 | }); 183 | 184 | test('Multiple imports', () => { 185 | primeImport(testImport('header.html'), header()); 186 | primeImport(testImport('footer.html'), footer()); 187 | 188 | const content = '

Content

'; 189 | 190 | const desiredOutput = `${header()} 191 | ${content} 192 | ${footer()}`; 193 | 194 | const output = compileTemplate(` 195 | ${content} 196 | `); 197 | 198 | expect(output).toBe(desiredOutput); 199 | }); 200 | 201 | test('A basic import with a slot', () => { 202 | primeImport(testImport('header.html'), header('')); 203 | const content = '

Content

'; 204 | 205 | const desiredOutput = header(content); 206 | const output = compileTemplate(` 207 | ${content} 208 | `); 209 | 210 | expect(output).toBe(desiredOutput); 211 | }); 212 | 213 | test('A basic import with a default slot', () => { 214 | const content = '

Content

'; 215 | primeImport( 216 | testImport('header.html'), 217 | header(`${content}`) 218 | ); 219 | 220 | const desiredOutput = header(content); 221 | const output = compileTemplate(``); 222 | 223 | expect(output).toBe(desiredOutput); 224 | }); 225 | 226 | test('A basic import with a named slot', () => { 227 | primeImport( 228 | testImport('header.html'), 229 | header(``) 230 | ); 231 | const content = '

Header

'; 232 | 233 | const desiredOutput = header(content); 234 | const output = compileTemplate(` 235 | 236 | ${content} 237 | 238 | `); 239 | 240 | expect(output).toBe(desiredOutput); 241 | }); 242 | 243 | test('Named and unnamed slots', () => { 244 | primeImport( 245 | testImport('header.html'), 246 | header(` 247 | `) 248 | ); 249 | const content = '

Header

'; 250 | 251 | const desiredOutput = header(`${content} 252 | ${content}`); 253 | const output = compileTemplate(` 254 | 255 | ${content} 256 | 257 | ${content} 258 | `); 259 | 260 | expect(output).toBe(desiredOutput); 261 | }); 262 | 263 | test('Default named slots', () => { 264 | const defaultContent = '

Header

'; 265 | primeImport( 266 | testImport('header.html'), 267 | header(`${defaultContent}`) 268 | ); 269 | 270 | const desiredOutput = header(defaultContent); 271 | const output = compileTemplate(``); 272 | 273 | expect(output).toBe(desiredOutput); 274 | }); 275 | }); 276 | 277 | describe('Markdown compilation', () => { 278 | test('A heading', () => { 279 | primeImport(testImport('about.md'), '# About us'); 280 | 281 | const desiredOutput = '

About us

'; 282 | const output = compileTemplate( 283 | '' 284 | ); 285 | 286 | expect(output).toBe(desiredOutput); 287 | }); 288 | 289 | test('Multiline markdown', () => { 290 | primeImport( 291 | testImport('about.md'), 292 | `# About us 293 | Content is **great**.` 294 | ); 295 | 296 | const desiredOutput = `

About us

297 |

Content is great.

`; 298 | 299 | const output = compileTemplate( 300 | '' 301 | ); 302 | 303 | expect(output).toBe(desiredOutput); 304 | }); 305 | 306 | test('Multiline markdown with code block', () => { 307 | primeImport( 308 | testImport('code.md'), 309 | `` 310 | ); 311 | 312 | primeImport( 313 | testImport('snippet.md'), 314 | `# Example code block 315 | 316 | \`\`\`html 317 |
318 | 319 |
320 | \`\`\` 321 | ` 322 | ); 323 | const desiredOutput = `

Example code block

324 |
<article>
325 |   <sergey-import src="code" as="markdown" />
326 | </article>
`; 327 | 328 | const output = compileTemplate( 329 | '' 330 | ); 331 | 332 | expect(output).toBe(desiredOutput); 333 | }); 334 | }); 335 | 336 | describe('Link compilation', () => { 337 | test('A link', () => { 338 | const input = `Example Link`; 339 | const desiredOutput = `Example Link`; 340 | const output = compileLinks(input); 341 | 342 | expect(output).toBe(desiredOutput); 343 | }); 344 | 345 | test('Multiple links', () => { 346 | const input = ` 347 | Example Link 1 348 | Example Link 2 349 | Example Link 3 350 | `; 351 | const desiredOutput = ` 352 | Example Link 1 353 | Example Link 2 354 | Example Link 3 355 | `; 356 | const output = compileLinks(input); 357 | 358 | expect(output).toBe(desiredOutput); 359 | }); 360 | 361 | test('A link to identical current path', () => { 362 | const input = `Example`; 363 | const path = '/example/index.html'; 364 | 365 | const desiredOutput = `Example`; 366 | const output = compileLinks(input, path); 367 | 368 | expect(output).toBe(desiredOutput); 369 | }); 370 | 371 | test('A link to start of current path', () => { 372 | const input = `Example`; 373 | const path = '/example/index.html'; 374 | 375 | const desiredOutput = `Example`; 376 | const output = compileLinks(input, path); 377 | 378 | expect(output).toBe(desiredOutput); 379 | }); 380 | 381 | test('A link to a parent path', () => { 382 | const input = `Example`; 383 | const path = '/example/foo/index.html'; 384 | 385 | const desiredOutput = `Example`; 386 | const output = compileLinks(input, path); 387 | 388 | expect(output).toBe(desiredOutput); 389 | }); 390 | 391 | test('Multiple links, with 1 current', () => { 392 | const path = '/example-1/'; 393 | const input = ` 394 | Example Link 1 395 | Example Link 2 396 | Example Link 3 397 | `; 398 | const desiredOutput = ` 399 | Example Link 1 400 | Example Link 2 401 | Example Link 3 402 | `; 403 | const output = compileLinks(input, path); 404 | 405 | expect(output).toBe(desiredOutput); 406 | }); 407 | 408 | test('Multiple links, with 1 parent', () => { 409 | const path = '/example-1/foo/index.html'; 410 | const input = ` 411 | Example Link 1 412 | Example Link 2 413 | Example Link 3 414 | `; 415 | const desiredOutput = ` 416 | Example Link 1 417 | Example Link 2 418 | Example Link 3 419 | `; 420 | const output = compileLinks(input, path); 421 | 422 | expect(output).toBe(desiredOutput); 423 | }); 424 | 425 | test('Home link, current', () => { 426 | const path = '/index.html'; 427 | const input = ` 428 | Home 429 | `; 430 | const desiredOutput = ` 431 | Home 432 | `; 433 | const output = compileLinks(input, path); 434 | 435 | expect(output).toBe(desiredOutput); 436 | }); 437 | 438 | test('Home link, not current', () => { 439 | const path = '/about/index.html'; 440 | const input = ` 441 | Home 442 | `; 443 | const desiredOutput = ` 444 | Home 445 | `; 446 | const output = compileLinks(input, path); 447 | 448 | expect(output).toBe(desiredOutput); 449 | }); 450 | 451 | test('Link to partial, not current', () => { 452 | const path = '/about/index.html'; 453 | const input = ` 454 | Subscribe 455 | `; 456 | const desiredOutput = ` 457 | Subscribe 458 | `; 459 | const output = compileLinks(input, path); 460 | 461 | expect(output).toBe(desiredOutput); 462 | }); 463 | 464 | test('Link with front-loaded classes', () => { 465 | const path = '/index.html'; 466 | const input = ` 467 | Home 468 | `; 469 | const desiredOutput = ` 470 | Home 471 | `; 472 | const output = compileLinks(input, path); 473 | 474 | expect(output).toBe(desiredOutput); 475 | }); 476 | 477 | test('Link with back-loaded classes', () => { 478 | const path = '/index.html'; 479 | const input = ` 480 | Home 481 | `; 482 | const desiredOutput = ` 483 | Home 484 | `; 485 | const output = compileLinks(input, path); 486 | 487 | expect(output).toBe(desiredOutput); 488 | }); 489 | 490 | test('Link with other attributes', () => { 491 | const path = '/index.html'; 492 | const input = ` 493 | Home 494 | `; 495 | const desiredOutput = ` 496 | Home 497 | `; 498 | const output = compileLinks(input, path); 499 | 500 | expect(output).toBe(desiredOutput); 501 | }); 502 | 503 | test('Link with ids and classes', () => { 504 | const path = '/index.html'; 505 | const input = ` 506 | Home 507 | `; 508 | const desiredOutput = ` 509 | Home 510 | `; 511 | const output = compileLinks(input, path); 512 | 513 | expect(output).toBe(desiredOutput); 514 | }); 515 | 516 | test('Link with href, rather than to', () => { 517 | const input = ` 518 | Example Link 1 519 | `; 520 | const desiredOutput = ` 521 | Example Link 1 522 | `; 523 | const output = compileLinks(input); 524 | 525 | expect(output).toBe(desiredOutput); 526 | }); 527 | }); 528 | --------------------------------------------------------------------------------