@moonwave99/goffre 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ (The MIT License)
2
+
3
+ Copyright (c) 2021 Diego Caponera <hello@diegocaponera.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ 'Software'), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Goffre
2
+
3
+ Goffre is a minimal static site generator available to the **node.js** ecosystem.
4
+
5
+ It uses [handlebars][handlebars] as templating system and [markdown + frontmatter][mdfront] as data layer, or whatever you decide to pass to `render()`.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ $ npm install goffre --save
11
+ ```
12
+
13
+ ## Basic Usage
14
+
15
+ ```js
16
+ import { load, render } from "goffre";
17
+
18
+ (async () => {
19
+ const { pages } = await load();
20
+
21
+ try {
22
+ const results = await render({ pages });
23
+ console.log(`Generated ${results.length} pages`);
24
+ } catch (error) {
25
+ console.log("Error generating site", error);
26
+ }
27
+ })();
28
+ ```
29
+
30
+ Default paths:
31
+
32
+ - **markdown files**: `./data` - used by `load()`
33
+ - **output folder**: `./dist` - used by `render()`
34
+ - **handlebars views**: `./src/views` - used by `render()`
35
+
36
+ See [examples](#examples) for a more advanced use case, and the [documentation][docs] for the complete reference.
37
+
38
+ ## Data collecting and rendering are separate steps
39
+
40
+ This is the key for the maximum flexibility: `load()` gets all the `.md` files inside the data folder, and populates its return `pages` each with a unique `slug`.
41
+
42
+ The markdown body is available in the `content` key, and the YAML front matter is destructured - the output of `load()` of the following file:
43
+
44
+ ```yaml
45
+ ---
46
+ title: "Goffre | Mini static site generator"
47
+ slug: "index"
48
+ ---
49
+ Goffre is a minimal static site generator available to the **node.js** ecosystem.
50
+ ```
51
+
52
+ will be:
53
+
54
+ ```js
55
+ {
56
+ title: "Goffre | Mini static site generator",
57
+ slug: "index",
58
+ content: "Goffre is a minimal static site generator available to the **node.js** ecosystem."
59
+ }
60
+ ```
61
+
62
+ **Note:** the markdown body is not yet parsed at this stage.
63
+
64
+ The `render()` method writes then every incoming page to `{page.slug}.html` - you can add further pages to the collected ones, like the text you are reading from the main `README.md` file of the repository:
65
+
66
+ ```js
67
+ const { pages } = await load({ dataPath });
68
+ const results = await render({
69
+ buildPath,
70
+ sitePath,
71
+ pages: [
72
+ ...pages,
73
+ {
74
+ title: "Goffre | Mini static site generator",
75
+ description:
76
+ "Goffre is a minimal static site generator available to the node.js ecosystem.",
77
+ slug: "index",
78
+ content: await readFile(path.join("..", "README.md"), "utf8"),
79
+ },
80
+ ],
81
+ });
82
+ ```
83
+
84
+ ## For a better development experience
85
+
86
+ Goffre does not provide any watching / serving features out of the box, but don't worry.
87
+
88
+ **Serving**: if you use [webpack][webpack] for bundling the frontend CSS and JS, just use its [dev server][webpack-dev-server] - see the [configuration file for this very page][webpack-config] as reference. If you don't, a simple [http-server][http-server] will do.
89
+
90
+ **Watching**: use [nodemon][nodemon] to watch the _generation script_, the _data folder_ and the _handlebars views folder_:
91
+
92
+ ```bash
93
+ $ nodemon -e js,json,md,handlebars --watch index.js --watch data --watch src/views
94
+ ```
95
+
96
+ The scripts of `package.json` will look more or less like:
97
+
98
+ ```json
99
+ {
100
+ "clean": "rm -rf dist",
101
+ "dev:client": "webpack serve --mode development",
102
+ "dev:site": "nodemon -e js,json,md,handlebars --watch index.js --watch data --watch src/views",
103
+ "build:client": "webpack --mode production",
104
+ "build:site": "node index.js"
105
+ }
106
+ ```
107
+
108
+ Just `npm run dev:client` and `npm run dev:site` in two terminal tabs and you are done. Don't forget to `npm install` the needed dependencies of course!
109
+
110
+ ## Examples
111
+
112
+ - [devblog][examples-devblog] - a personal website with blog posts and project pages
113
+ - this page of course
114
+
115
+ [handlebars]: https://handlebarsjs.com/
116
+ [express-handlebars]: https://www.npmjs.com/package/express-handlebars
117
+ [mdfront]: https://www.google.com/search?q=markdown+frontmatter
118
+ [webpack]: https://webpack.js.org/
119
+ [webpack-dev-server]: https://webpack.js.org/configuration/dev-server/
120
+ [http-server]: https://www.npmjs.com/package/http-server
121
+ [nodemon]: https://www.npmjs.com/package/nodemon
122
+ [example]: https://github.com/moonwave99/goffre/tree/main/examples/devblog
123
+ [docs]: https://github.com/moonwave99/goffre/tree/main/examples/devblog
124
+ [webpack-config]: https://github.com/moonwave99/goffre/blob/main/homepage/webpack.config.cjs
125
+ [examples-devblog]: https://goffre-examples-devblog.netlify.app/
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./lib/goffre.js";
package/lib/goffre.js ADDED
@@ -0,0 +1,297 @@
1
+ import path from "path";
2
+ import { globby } from "globby";
3
+ import { marked } from "marked";
4
+ import matter from "gray-matter";
5
+ import { createRequire } from "module";
6
+ import fs from "fs-extra";
7
+ import express from "express";
8
+ import { engine } from "express-handlebars";
9
+ import chalk from "chalk";
10
+ import slugify from "slugify";
11
+ import * as defaultHelpers from "./helpers.js";
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { readFile, outputFile } = fs;
15
+
16
+ const DEFAULT_DATA_PATH = path.join(process.cwd(), "data");
17
+ const DEFAULT_VIEWS_PATH = path.join(process.cwd(), "src", "views");
18
+ const DEFAULT_BUILD_PATH = path.join(process.cwd(), "dist");
19
+ const MAX_SLUG_LOG_LENGTH = 40;
20
+
21
+ function log() {
22
+ console.log.apply(
23
+ null,
24
+ ["[goffre]", ...arguments].map((x) => chalk.cyan(x))
25
+ );
26
+ }
27
+
28
+ function getEnv() {
29
+ return {
30
+ mode: process.env.MODE || "dev",
31
+ };
32
+ }
33
+
34
+ function stringify(token) {
35
+ if (token instanceof Date) {
36
+ return token.toISOString().split("T")[0];
37
+ }
38
+ return token;
39
+ }
40
+
41
+ export function getSlug(slug, params) {
42
+ return slug
43
+ .split("/")
44
+ .reduce(
45
+ (memo, x) =>
46
+ !x.startsWith(":")
47
+ ? [...memo, x]
48
+ : [
49
+ ...memo,
50
+ slugify(stringify(params[x.slice(1)]), {
51
+ lower: true,
52
+ strict: true,
53
+ }),
54
+ ],
55
+ []
56
+ )
57
+ .join("/");
58
+ }
59
+
60
+ export function getTemplate({ page, templates, defaultTemplate = "_default" }) {
61
+ if (page.template) {
62
+ return page.template;
63
+ }
64
+ if (templates.find((x) => x.startsWith(page.slug))) {
65
+ return page.slug;
66
+ }
67
+ return defaultTemplate;
68
+ }
69
+
70
+ function renderPage({ app, templates, buildPath, maxSlugLogLength, ...page }) {
71
+ return new Promise((resolve, reject) => {
72
+ const template = getTemplate({ page, templates });
73
+
74
+ switch (app.locals.options.logLevel) {
75
+ case "silent":
76
+ break;
77
+ case "verbose":
78
+ log(
79
+ `Generating ${chalk.yellow(
80
+ page.slug.padEnd(maxSlugLogLength, " ")
81
+ )} with template ${chalk.green(template)}...`
82
+ );
83
+ break;
84
+ case "normal":
85
+ default:
86
+ log(`Generating ${chalk.yellow(page.slug)}...`);
87
+ }
88
+
89
+ app.render(
90
+ template,
91
+ {
92
+ ...page,
93
+ layout:
94
+ typeof page.layout === "undefined" ? "main" : page.layout,
95
+ content: page.content ? marked.parse(page.content) : null,
96
+ },
97
+ async (error, html) => {
98
+ if (error) {
99
+ reject(error);
100
+ return;
101
+ }
102
+ const outputFileName = `${page.slug}${page.extname || ".html"}`;
103
+ await outputFile(path.join(buildPath, outputFileName), html);
104
+ resolve({
105
+ ...page,
106
+ outputFileName,
107
+ });
108
+ }
109
+ );
110
+ });
111
+ }
112
+
113
+ export async function load({ dataPath } = {}) {
114
+ return {
115
+ json: await loadJSON(dataPath || DEFAULT_DATA_PATH),
116
+ pages: await loadMarkdown(dataPath || DEFAULT_DATA_PATH),
117
+ };
118
+ }
119
+
120
+ export async function loadJSON(cwd) {
121
+ const files = await globby("**/*.json", { cwd });
122
+ return files.reduce(
123
+ (memo, x) => ({
124
+ ...memo,
125
+ [path.basename(x, ".json")]: require(path.join(cwd, x)),
126
+ }),
127
+ {}
128
+ );
129
+ }
130
+
131
+ function excerpt(file) {
132
+ file.excerpt = file.content.split("\n")[1];
133
+ }
134
+
135
+ export async function loadMarkdown(cwd) {
136
+ const files = await globby("**/*.md", { cwd });
137
+ return Promise.all(
138
+ files.map(async (fileName) => {
139
+ const fullPath = path.join(cwd, fileName);
140
+ const contents = await readFile(fullPath, "utf-8");
141
+ const parsed = matter(contents, { excerpt });
142
+ const outputFileName = fileName.replace(".md", "");
143
+ const slug = !parsed.data.slug
144
+ ? outputFileName
145
+ : getSlug(parsed.data.slug, parsed.data);
146
+ return {
147
+ ...parsed.data,
148
+ excerpt: parsed.excerpt,
149
+ slug,
150
+ description: parsed.data.description || parsed.excerpt,
151
+ content: parsed.content,
152
+ };
153
+ })
154
+ );
155
+ }
156
+
157
+ export const getSorter =
158
+ ({ sortBy, order }) =>
159
+ (a, b) => {
160
+ let output;
161
+ if (a[sortBy] instanceof Date) {
162
+ output = new Date(a[sortBy]) - new Date(b[sortBy]);
163
+ } else {
164
+ output = a[sortBy] - b[sortBy];
165
+ }
166
+ return order === "desc" ? -output : output;
167
+ };
168
+
169
+ export async function render({
170
+ pages,
171
+ viewsPath = DEFAULT_VIEWS_PATH,
172
+ buildPath = DEFAULT_BUILD_PATH,
173
+ domain,
174
+ uglyUrls = false,
175
+ logLevel = "normal",
176
+ locals = {},
177
+ markdown = {},
178
+ handlebars = {},
179
+ sitemap = {},
180
+ env = {},
181
+ }) {
182
+ const extname = handlebars.extname || ".handlebars";
183
+ const app = express();
184
+ app.engine(
185
+ extname,
186
+ engine({
187
+ ...handlebars,
188
+ helpers: {
189
+ ...defaultHelpers,
190
+ ...handlebars.helpers,
191
+ },
192
+ })
193
+ );
194
+ app.set("view engine", "handlebars");
195
+ app.set("layoutsDir", path.join(viewsPath, "layouts"));
196
+ app.set("views", viewsPath);
197
+
198
+ const templates = await globby(`**/*${extname}`, {
199
+ cwd: viewsPath,
200
+ });
201
+
202
+ app.locals = {
203
+ ...app.locals,
204
+ ...locals,
205
+ options: {
206
+ domain,
207
+ uglyUrls,
208
+ logLevel,
209
+ },
210
+ env: { ...getEnv(), ...env },
211
+ };
212
+
213
+ marked.use(markdown);
214
+
215
+ switch (logLevel) {
216
+ case "silent":
217
+ break;
218
+ case "verbose":
219
+ case "normal":
220
+ default:
221
+ log(`Start generation...`);
222
+ }
223
+
224
+ const results = await Promise.all(
225
+ pages.map((x) =>
226
+ renderPage({
227
+ ...x,
228
+ buildPath,
229
+ app,
230
+ templates,
231
+ maxSlugLogLength: Math.min(
232
+ Math.max.call(null, ...pages.map((x) => x.slug.length)),
233
+ MAX_SLUG_LOG_LENGTH
234
+ ),
235
+ })
236
+ )
237
+ );
238
+
239
+ switch (logLevel) {
240
+ case "silent":
241
+ break;
242
+ case "verbose":
243
+ case "normal":
244
+ default:
245
+ log(`Generated ${results.length} pages`);
246
+ }
247
+
248
+ if (sitemap.generate) {
249
+ renderPage({
250
+ slug: "sitemap",
251
+ template: sitemap.template || "sitemap",
252
+ extname: ".xml",
253
+ layout: null,
254
+ pages: results,
255
+ buildPath,
256
+ app,
257
+ templates,
258
+ });
259
+ }
260
+
261
+ return results;
262
+ }
263
+
264
+ export function paginate({
265
+ collection,
266
+ size = 10,
267
+ sortBy = "slug",
268
+ order = "asc",
269
+ }) {
270
+ const total = Math.ceil(collection.length / size);
271
+ return collection
272
+ .sort(getSorter({ sortBy, order }))
273
+ .reduce((memo, x, index) => {
274
+ if (index % size === 0) {
275
+ const page = Math.floor(index / size) + 1;
276
+ return [
277
+ ...memo,
278
+ {
279
+ pagination: {
280
+ page,
281
+ prev: page > 1 ? page - 1 : null,
282
+ next: page < total ? page + 1 : null,
283
+ total,
284
+ },
285
+ items: [x],
286
+ },
287
+ ];
288
+ }
289
+ return [
290
+ ...memo.slice(0, -1),
291
+ {
292
+ ...memo[memo.length - 1],
293
+ items: [...memo[memo.length - 1].items, x],
294
+ },
295
+ ];
296
+ }, []);
297
+ }
package/lib/helpers.js ADDED
@@ -0,0 +1,84 @@
1
+ import { getSorter, getSlug } from "./goffre.js";
2
+ import { marked } from "marked";
3
+
4
+ export const markdown = (text) => marked(text);
5
+
6
+ export const getParamLink = (url, options) => {
7
+ const output = getSlug(url, options.hash);
8
+ return getAsset(output, options);
9
+ };
10
+
11
+ export const getAsset = (asset, context) => {
12
+ const { options, env } = context.data.root;
13
+ return env.mode === "prod" && options.domain
14
+ ? `${options.domain}${asset.startsWith("/") ? "" : "/"}${asset}`
15
+ : asset;
16
+ };
17
+
18
+ export const getSitemapLink = (page, context) => {
19
+ const { options } = context.data.root;
20
+ return `${options.domain}${getLink(page, context)}`;
21
+ };
22
+
23
+ export const getLink = (page, context) => {
24
+ const base =
25
+ page.link || `${page.slug.startsWith("/") ? "" : "/"}${page.slug}`;
26
+ const { uglyUrls } = context.data.root.options;
27
+ if (uglyUrls) {
28
+ return getAsset(`${base === "/" ? "/index" : base}.html`, context);
29
+ }
30
+ return getAsset(base.replace(/^\/index/, "/"), context);
31
+ };
32
+
33
+ export const getNavClass = ({ slug }, currentPage) => {
34
+ const cleanSlug = slug && slug[0] === "/" ? slug.slice(1) : slug;
35
+ return currentPage.slug.startsWith(cleanSlug)
36
+ ? `${cleanSlug} current`
37
+ : cleanSlug;
38
+ };
39
+
40
+ export const list = (context, options) => {
41
+ const offset = parseInt(options.hash.offset, 10) || 0;
42
+ const limit = parseInt(options.hash.limit, 10) || 100;
43
+ const sortBy = options.hash.sortBy || "slug";
44
+ const order = options.hash.order || "asc";
45
+
46
+ let output = "";
47
+ let i, j;
48
+
49
+ const data = [...context].sort(getSorter({ sortBy, order }));
50
+
51
+ if (offset < 0) {
52
+ i = -offset < data.length ? data.length - -offset : 0;
53
+ } else {
54
+ i = offset < data.length ? offset : 0;
55
+ }
56
+
57
+ j = limit + i < data.length ? limit + i : data.length;
58
+
59
+ for (i, j; i < j; i++) {
60
+ output += options.fn(data[i]);
61
+ }
62
+
63
+ return output;
64
+ };
65
+
66
+ export const nextItem = (context, options) => {
67
+ const { list } = options.hash;
68
+ const index = list.findIndex((x) => x.slug === context.slug);
69
+ const next = list[index + 1];
70
+ if (!next) {
71
+ return;
72
+ }
73
+ return options.fn(next);
74
+ };
75
+
76
+ export const prevItem = (context, options) => {
77
+ const { list } = options.hash;
78
+ const index = list.findIndex((x) => x.slug === context.slug);
79
+ const prev = list[index - 1];
80
+ if (!prev) {
81
+ return;
82
+ }
83
+ return options.fn(prev);
84
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@moonwave99/goffre",
3
+ "version": "0.0.4",
4
+ "description": "Mini static site generator",
5
+ "author": {
6
+ "name": "Diego Caponera",
7
+ "email": "hello@diegocaponera.com",
8
+ "url": "https://github.com/moonwwave99"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/moonwave99/goffre.git"
13
+ },
14
+ "homepage": "https://moonwave99.github.io/goffre",
15
+ "type": "module",
16
+ "scripts": {
17
+ "test": "ava **/*.test.js",
18
+ "lint": "npx eslint ./lib --ext .js,.jsx,.ts,.tsx"
19
+ },
20
+ "main": "index.js",
21
+ "keywords": [
22
+ "front-matter",
23
+ "generate",
24
+ "generator",
25
+ "markdown",
26
+ "static",
27
+ "static-site",
28
+ "yaml"
29
+ ],
30
+ "files": [
31
+ "lib/goffre.js",
32
+ "lib/helpers.js"
33
+ ],
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "chalk": "^4.1.2",
37
+ "express": "^4.17.1",
38
+ "express-handlebars": "^6.0.1",
39
+ "fs-extra": "^10.0.0",
40
+ "globby": "^12.0.2",
41
+ "gray-matter": "^4.0.3",
42
+ "marked": "^4.0.3",
43
+ "slugify": "^1.6.2"
44
+ },
45
+ "devDependencies": {
46
+ "ava": "^3.15.0",
47
+ "cheerio": "^1.0.0-rc.10",
48
+ "dirname-filename-esm": "^1.1.1",
49
+ "eslint": "^8.3.0",
50
+ "faker": "^5.5.3",
51
+ "js-yaml": "^4.1.0",
52
+ "rimraf": "^3.0.2",
53
+ "sinon": "^12.0.1"
54
+ }
55
+ }