mikel-press 0.2.0 → 0.18.1

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.
Files changed (4) hide show
  1. package/README.md +137 -19
  2. package/index.js +377 -180
  3. package/package.json +16 -20
  4. package/index.d.ts +0 -55
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # mikel-press
2
2
 
3
- **mikel-press** is a tiny static site generator inspired by Jekyll that uses **mikel** as its templating engine. It provides a simple and efficient way to build static websites with customizable layouts and content.
3
+ ![npm version](https://badgen.net/npm/v/mikel-press?labelColor=1d2734&color=21bf81)
4
+ ![license](https://badgen.net/github/license/jmjuanes/mikel?labelColor=1d2734&color=21bf81)
5
+
6
+ **mikel-press** is a static site generator inspired by [Jekyll](https://jekyllrb.com/) and built on top of **mikel**, a Mustache-based templating engine. It allows you to generate static websites from HTML and Markdown files using a flexible plugin system.
4
7
 
5
8
  ## Installation
6
9
 
@@ -16,37 +19,152 @@ Or **npm**:
16
19
  $ npm install --dev mikel-press
17
20
  ```
18
21
 
19
- ## Usage
22
+ ## Configuration
23
+
24
+ **mikel-press** can be configured using a `config` object that accepts the following options:
25
+
26
+ | Field | Description | Default |
27
+ |-------|-------------|---------|
28
+ | `source` | The path to the directory containing the site's HTML or Markdown files. | `"content"` |
29
+ | `destination` | The output directory where the generated static site will be saved. | `"www"` |
30
+ | `layout` | The path to the layout file that will be used as the base template for all pages. | - |
31
+ | `plugins` | A list of plugins used to extend the functionality of mikel-press. | `[]` |
32
+ | `*` | Any other properties passed in config will be available as `site.*` inside each page template. | - |
33
+
34
+ Here is an example configuration object:
35
+
36
+ ```javascript
37
+ const config = {
38
+ source: "./content",
39
+ destination: "./www",
40
+ layout: "./layout.html",
41
+ title: "Hello world",
42
+ description: "My awesome site",
43
+ plugins: [
44
+ press.SourcePlugin(),
45
+ press.FrontmatterPlugin(),
46
+ press.PermalinkPlugin(),
47
+ press.ContentPlugin(),
48
+ press.CopyAssetsPlugin({
49
+ patterns: [
50
+ { from: "./static/styles.css", to: "static/" },
51
+ ],
52
+ }),
53
+ ],
54
+ };
55
+ ```
20
56
 
21
- ### Building the Site
57
+ ## Content
22
58
 
23
- Run the `build` command to generate your static website:
59
+ ### Variables
24
60
 
25
- ```bash
26
- $ mikel-press build
27
- ```
61
+ Each HTML file processed by **mikel-press** will be handled by the mikel templating engine, that will provide the following data variables to each page:
28
62
 
29
- The generated files will be available in the `www` directory by default.
63
+ #### Global variables
30
64
 
31
- ### (WIP) Starting a Development Server
65
+ | Variable | Description |
66
+ |----------|-------------|
67
+ | `site` | Contains the site information and all the additional keys provided in the configuration object. |
68
+ | `page` | Specific information about the page that is rendered. |
69
+ | `layout` | Specific information about the layout that is used for renderin the page. |
32
70
 
33
- To preview your site locally, use the `serve` command:
71
+ #### Site variables
34
72
 
35
- ```bash
36
- mikel-press serve
37
- ```
73
+ | Variable | Description |
74
+ |----------|-------------|
75
+ | `site.data` | An object containing all data items loaded by `DataPlugin`. |
76
+ | `site.pages` | A list containing all pages processed by **mikel-pres**. |
77
+ | `site.*` | All the additional configuration fields provided in the configuration. |
78
+
79
+ #### Page variables
80
+
81
+ | Variable | Description |
82
+ |----------|-------------|
83
+ | `page.path` | The path to the page. Example: `about/index.html`. |
84
+ | `page.url` | The path to the page including the leading `/`. Example: `/about/index.html`. |
85
+ | `page.attributes` | An object containing all the frontmatter variables in the page processed by `FrontmatterPlugin`. |
86
+ | `page.content` | The raw content of the page before begin processed by **mikel**. |
87
+
88
+ #### Layout variables
89
+
90
+ | Variable | Description |
91
+ |----------|-------------|
92
+ | `layout.attributes` | An object containing all the frontmatter variables in the layout processed by `FrontmatterPlugin`. |
93
+ | `layout.content` | The raw content of the layout. |
94
+
95
+ ## Plugins
96
+
97
+ **mikel-press** relies on plugins to handle file reading, transformation, and rendering. The following plugins are built-in:
98
+
99
+ ### `press.SourcePlugin(options)`
100
+
101
+ This plugin reads content from the specified `config.source` directory and loads it into the system for processing.
102
+
103
+ Options:
104
+ - `options.source` (string): Specifies a custom source directory. If not provided, `config.source` is used.
105
+ - `options.extensions` (array): Defines the file extensions that should be processed. The default value is `[".html", ".md", ".markdown"]`.
106
+
107
+ ### `press.DataPlugin(options)`
108
+
109
+ This plugin loads JSON files from the specified directory and makes them available in the site context.
110
+
111
+ Options:
112
+ - `options.source` (string): Specifies a custom source directory for data files. If not provided, `./data` is used.
113
+
114
+ ### `press.FrontmatterPlugin(options)`
38
115
 
39
- This will start a local server at `http://localhost:4000`.
116
+ This plugin processes frontmatter in Markdown and HTML files.
40
117
 
41
- ## (WIP) Configuration
118
+ Options:
119
+ - `options.extensions` (array): Defines the file extensions that should be processed. The default value is `[".md", ".markdown", ".html"]`.
120
+ - `options.parser` (function): Frontmatter parser function (e.g., `JSON.parse`, `YAML.load`).
42
121
 
43
- Create a file called `press.config.js` with the configuration.
122
+ ### `press.PermalinkPlugin()`
44
123
 
124
+ This plugin allows defining custom permalinks for pages.
45
125
 
46
- ## Contributing
126
+ ### `press.MarkdownPlugin(options)`
47
127
 
48
- Contributions are welcome! If you have ideas or find a bug, feel free to open an issue or submit a pull request.
128
+ This plugin processes Markdown files and converts them to HTML.
129
+
130
+ Options:
131
+ - `options.parser` (function): Markdown parser function (e.g., `marked.parse`).
132
+
133
+ ### `press.ContentPlugin(options)`
134
+
135
+ This plugin processes each page and saves it into `config.destination`. It accepts an `options` object, which is passed to mikel for template processing.
136
+
137
+ ### `press.CopyAssetsPlugin(options)`
138
+
139
+ This plugin copies static files from the source to the destination.
140
+
141
+ Options:
142
+ - `options.patterns` (array): List of file patterns to copy. Each pattern should have `from` and `to`.
143
+
144
+ ## Node API
145
+
146
+ **mikel-press** provides an API with two main methods:
147
+
148
+ ### `press.build(config)`
149
+
150
+ Triggers the build of **mikel-press** with the given configuration object provided as an argument.
151
+
152
+ ```javascript
153
+ import press from "mikel-press";
154
+
155
+ press.build(config);
156
+ ```
157
+
158
+ ### `press.watch(config)`
159
+
160
+ Calling the `watch` method triggers the build, but also watches for changes and rebuilds the site as soon as it detects a change in any of the source files.
161
+
162
+ ```javascript
163
+ import press from "mikel-press";
164
+
165
+ press.watch(config);
166
+ ```
49
167
 
50
168
  ## License
51
169
 
52
- This project is licensed under the MIT License.
170
+ This project is licensed under the [MIT License](../../LICENSE).
package/index.js CHANGED
@@ -2,203 +2,400 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import mikel from "mikel";
4
4
 
5
- // @description tiny yaml parser
6
- const parseYaml = (str = "") => {
7
- const lines = str.split("\n").filter(line => line.trim() !== "" && !line.trim().startsWith("#"));
8
- return Object.fromEntries(lines.map(line => {
9
- const [key, value] = line.split(":").map(part => part.trim());
10
- if (!isNaN(value)) {
11
- return [key, Number(value)];
5
+ // @description general utilities
6
+ const utils = {
7
+ // @description read a file from disk
8
+ // @param {String} file path to the file to read
9
+ read: (file, encoding = "utf8") => {
10
+ return fs.readFileSync(file, encoding);
11
+ },
12
+ // @description write a file to disk
13
+ // @param {String} file path to the file to save
14
+ // @param {String} content content to save
15
+ write: (file, content = "") => {
16
+ const folder = path.dirname(file);
17
+ if (!fs.existsSync(folder)) {
18
+ fs.mkdirSync(folder, {recursive: true});
12
19
  }
13
- if (value === "true" || value === "false" || value === "null") {
14
- return [key, JSON.parse(value)];
20
+ fs.writeFileSync(file, content, "utf8");
21
+ },
22
+ // @description copy a file
23
+ copy: (source, target) => {
24
+ const folder = path.dirname(target);
25
+ if (!fs.existsSync(folder)) {
26
+ fs.mkdirSync(folder, {recursive: true});
15
27
  }
16
- return [key, value.replaceAll(/^["']|["']$/g, "")];
17
- }));
18
- };
19
-
20
- // @description tiny front-matter parser
21
- const frontmatter = (str = "") => {
22
- let body = (str || "").trim(), attributes = {};
23
- const matches = Array.from(body.matchAll(/^(--- *)/gm));
24
- if (matches?.length === 2 && matches[0].index === 0) {
25
- attributes = parseYaml(body.substring(0 + matches[0][1].length, matches[1].index).trim());
26
- body = body.substring(matches[1].index + matches[1][1].length).trim();
27
- }
28
- return {body, attributes};
29
- };
30
-
31
- // @description utility to save a file to disk
32
- const saveFile = (filePath, fileContent) => {
33
- const folder = path.dirname(filePath);
34
- if (!fs.existsSync(folder)) {
35
- fs.mkdirSync(folder, {recursive: true});
36
- }
37
- fs.writeFileSync(filePath, fileContent, "utf8");
38
- };
39
-
40
- // @description returns the layout content from the given options
41
- const getLayoutContent = config => {
42
- let content = "";
43
- // using options.template to specify the the absolute path to the template file
44
- if (typeof config?.layout === "string" || typeof config?.template === "string") {
45
- content = fs.readFileSync(config.layout || config.template, "utf8");
46
- }
47
- // using templateContent to specify the string content of the template
48
- if (typeof config?.layoutContent === "string" || typeof config?.templateContent === "string") {
49
- content = config.layoutContent || config.templateContent;
50
- }
51
- // parse with frontmatter
52
- const {body, attributes} = typeof config.frontmatter == "function" ? config.frontmatter(content) : {body: content, attributes: {}};
28
+ fs.copyFileSync(source, target);
29
+ },
30
+ // @description get all files from the given folder and the given extensions
31
+ readdir: (folder, extensions = "*") => {
32
+ if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
33
+ return [];
34
+ }
35
+ return fs.readdirSync(folder, "utf8").filter(file => {
36
+ return extensions === "*" || extensions.includes(path.extname(file));
37
+ });
38
+ },
39
+ // @description walk through the given folder and get all files
40
+ // @params {String} folder folder to walk through
41
+ // @params {Array|String} extensions extensions to include. Default: "*"
42
+ walkdir: (folder, extensions = "*") => {
43
+ const files = [];
44
+ const walkSync = currentFolder => {
45
+ fs.readdirSync(currentFolder).forEach(file => {
46
+ const pathToFile = path.join(currentFolder, file);
47
+ if (fs.statSync(pathToFile).isDirectory()) {
48
+ return walkSync(pathToFile);
49
+ }
50
+ if (extensions === "*" || extensions.includes(path.extname(file))) {
51
+ files.push(pathToFile);
52
+ }
53
+ });
54
+ };
55
+ walkSync(folder);
56
+ return files;
57
+ },
58
+ // @description watch for file changes
59
+ // @param {String} filePath path to the file to watch
60
+ // @param {Function} listener method to listen for file changes
61
+ watch: (filePath, listener) => {
62
+ let lastModifiedTime = null;
63
+ fs.watch(filePath, "utf8", () => {
64
+ const modifiedTime = fs.statSync(filePath).mtimeMs;
65
+ if (lastModifiedTime !== modifiedTime) {
66
+ lastModifiedTime = modifiedTime;
67
+ return listener(filePath);
68
+ }
69
+ });
70
+ },
71
+ // @description change the properties of the given path (dirname, basename, extname)
72
+ format: (filePath, options = {}) => {
73
+ const dirname = options.dirname || path.dirname(filePath);
74
+ const extname = options.extname || path.extname(filePath);
75
+ const basename = options.basename || path.basename(filePath, path.extname(filePath));
76
+ return path.join(dirname, `${basename}${extname}`);
77
+ },
78
+ };
79
+
80
+ // @description add a new node item
81
+ const createNode = (source, path, label = "", data = {}) => {
82
+ return {source, path, label, data};
83
+ };
84
+
85
+ // @description get nodes with the specified label
86
+ const getNodesByLabel = (nodes, label) => {
87
+ return Array.from(nodes).filter(node => node.label === label);
88
+ };
89
+
90
+ // @description get all nodes to update
91
+ const getNodesToUpdate = (graph, affectedNode) => {
92
+ const listOfAffectedNodes = new Set();
93
+ const walkNodes = currentNode => {
94
+ listOfAffectedNodes.add(currentNode);
95
+ return graph.forEach(edge => {
96
+ if (edge[0] === currentNode && !listOfAffectedNodes.has(edge[1])) {
97
+ walkNodes(edge[1]);
98
+ }
99
+ });
100
+ };
101
+ walkNodes(affectedNode);
102
+ return listOfAffectedNodes;
103
+ };
104
+
105
+ // @description get plugins with the specified function
106
+ const getPlugins = (plugins, functionName) => {
107
+ return plugins.filter(plugin => typeof plugin[functionName] === "function");
108
+ };
109
+
110
+ // create a new context from the provided configuration
111
+ const createContext = config => {
112
+ const {source, destination, plugins, ...otherConfiguration} = config;
113
+ const context = Object.freeze({
114
+ config: otherConfiguration,
115
+ source: path.resolve(source || "."),
116
+ destination: path.resolve(destination || "./www"),
117
+ plugins: plugins || [],
118
+ nodes: [],
119
+ edges: [],
120
+ });
121
+ // load nodes into context
122
+ const nodesPaths = new Set(); // prevent adding duplicated nodes
123
+ getPlugins(context.plugins, "load").forEach(plugin => {
124
+ const nodes = plugin.load(context) || [];
125
+ [nodes].flat().forEach(node => {
126
+ const nodeFullPath = path.join(node.source, node.path);
127
+ if (nodesPaths.has(nodeFullPath)) {
128
+ throw new Error(`File ${nodeFullPath} has been already processed by another plugin`);
129
+ }
130
+ context.nodes.push(node);
131
+ nodesPaths.add(nodeFullPath);
132
+ });
133
+ });
134
+ // generate dependency graph
135
+ const edgesPaths = new Set(); // prevent adding duplicated edges
136
+ getPlugins(context.plugins, "getDependencyGraph").forEach(plugin => {
137
+ (plugin.getDependencyGraph(context) || []).forEach(edge => {
138
+ if (!edge.every(node => nodesPaths.has(node))) {
139
+ throw new Error(`Dependency graph contains nodes that have not been loaded`);
140
+ }
141
+ const edgePath = edge.join(" -> ");
142
+ if (!edgesPaths.has(edgePath)) {
143
+ context.edges.push(edge);
144
+ edgesPaths.add(edgePath);
145
+ }
146
+ });
147
+ });
148
+ // return context
149
+ return context;
150
+ };
151
+
152
+ // @description build context
153
+ const buildContext = (context, nodes = null) => {
154
+ const nodesToBuild = (nodes && Array.isArray(nodes)) ? nodes : context.nodes;
155
+ // reset nodes path
156
+ nodesToBuild.forEach(node => {
157
+ node.data.path = node.path;
158
+ });
159
+ // transform nodes
160
+ const transformPlugins = getPlugins(context.plugins, "transform");
161
+ nodesToBuild.forEach((node, _, allNodes) => {
162
+ transformPlugins.forEach(plugin => {
163
+ return plugin.transform(context, node, allNodes);
164
+ });
165
+ });
166
+ // filter nodes and get only the ones that are going to be emitted
167
+ const shouldEmitPlugins = getPlugins(context.plugins, "shouldEmit");
168
+ const filteredNodes = nodesToBuild.filter((node, _, allNodes) => {
169
+ for (let i = 0; i < shouldEmitPlugins.length; i++) {
170
+ const plugin = shouldEmitPlugins[i];
171
+ if (!plugin.shouldEmit(context, node, allNodes)) {
172
+ return false;
173
+ }
174
+ }
175
+ return true;
176
+ });
177
+ // emit each node
178
+ getPlugins(context.plugins, "emit").forEach(plugin => {
179
+ return plugin.emit(context, filteredNodes);
180
+ });
181
+ };
182
+
183
+ // @description start a watch on the current context
184
+ const watchContext = context => {
185
+ // force to rebuild
186
+ const rebuild = changedNodePath => {
187
+ const nodesPathsToBuild = getNodesToUpdate(context.edges, changedNodePath);
188
+ const nodesToRebuild = context.nodes.filter(node => {
189
+ return nodesPathsToBuild.has(path.join(node.source, node.path));
190
+ });
191
+ // perform the rebuild of the context
192
+ buildContext(context, nodesToRebuild);
193
+ };
194
+ // create a watch for each registered node in the context
195
+ context.nodes.forEach(node => {
196
+ return utils.watch(path.join(node.source, node.path), rebuild);
197
+ });
198
+ };
199
+
200
+ // @description source plugin
201
+ const SourcePlugin = (options = {}) => {
202
+ const label = options.label || "pages";
203
+ return {
204
+ name: "SourcePlugin",
205
+ load: context => {
206
+ const folder = path.resolve(context.source, options.source || "./content");
207
+ const nodes = utils.walkdir(folder, options?.extensions || "*").map(file => {
208
+ return createNode(folder, file, label);
209
+ });
210
+ return nodes;
211
+ },
212
+ transform: (_, node) => {
213
+ if (node.label === label) {
214
+ node.data.content = utils.read(path.join(node.source, node.path));
215
+ }
216
+ },
217
+ };
218
+ };
219
+
220
+ // @description data plugin
221
+ const DataPlugin = (options = {}) => {
222
+ const label = options?.label || "asset/data";
223
+ return {
224
+ name: "DataPlugin",
225
+ load: context => {
226
+ const folder = path.resolve(context.source, options.source || "./data");
227
+ return utils.readdir(folder, [".json"]).map(file => {
228
+ return createNode(folder, file, label);
229
+ });
230
+ },
231
+ transform: (_, node) => {
232
+ if (node.label === label && path.extname(node.path) === ".json") {
233
+ node.data.name = path.basename(node.path, ".json");
234
+ node.data.content = JSON.parse(utils.read(path.join(node.source, node.path)));
235
+ }
236
+ },
237
+ shouldEmit: (_, node) => {
238
+ return node.label !== label;
239
+ },
240
+ };
241
+ };
242
+
243
+ // @description frontmatter plugin
244
+ // @params {Object} options options for this plugin
245
+ // @params {Array} options.extensions extensions to process. Default: [".md", ".markdown", ".html"]
246
+ // @params {Function} options.parser frontmatter parser (JSON.parse, YAML.load)
247
+ const FrontmatterPlugin = (options = {}) => {
248
+ const extensions = options.extensions || [".md", ".markdown", ".html"];
53
249
  return {
54
- content: body,
55
- data: attributes || {},
56
- attributes: attributes || {},
250
+ name: "FrontmatterPlugin",
251
+ transform: (_, node) => {
252
+ if ((extensions === "*" || extensions.includes(path.extname(node.path))) && typeof node.data.content === "string") {
253
+ node.data.attributes = {};
254
+ const matches = Array.from(node.data.content.matchAll(/^(--- *)/gm))
255
+ if (matches?.length === 2 && matches[0].index === 0) {
256
+ const front = node.data.content.substring(0 + matches[0][1].length, matches[1].index).trim();
257
+ node.data.content = node.data.content.substring(matches[1].index + matches[1][1].length).trim();
258
+ if (typeof options.parser === "function") {
259
+ node.data.attributes = options.parser(front);
260
+ }
261
+ }
262
+ }
263
+ },
57
264
  };
58
265
  };
59
266
 
60
- // @description create a virtual page object from the given options
61
- const createVirtualPage = (options = {}) => {
62
- const content = options.content || fs.readFileSync(options.file, "utf8");
63
- const extname = options.extname || path.extname(options.file || "") || ".html";
64
- const basename = options.basename || path.basename(options.file || "", extname) || "virtual";
65
- const {body, attributes} = typeof options?.frontmatter == "function" ? options.frontmatter(content) : {body: content, attributes: {}};
267
+ // @description permalink plugin
268
+ const PermalinkPlugin = () => {
66
269
  return {
67
- name: basename + extname,
68
- basename: basename,
69
- extname: extname,
70
- url: options.url || attributes?.permalink || path.join("/", basename + extname),
71
- data: attributes || {}, // DEPRECATED
72
- attributes: attributes || {},
73
- content: typeof options.transform === "function" ? options.transform(body) : body,
270
+ name: "PermalinkPlugin",
271
+ transform: (_, node) => {
272
+ node.data.path = node.data?.attributes?.permalink || node.data.path;
273
+ // node.data.url = path.normalize("/" + node.data.path);
274
+ },
74
275
  };
75
276
  };
76
277
 
77
- // @description get pages from input folder
78
- const readPages = (folder, extensions = ".html", fm = null, transform = null) => {
79
- const extensionsList = new Set([extensions].flat());
80
- if (fs.existsSync(folder) && fs.lstatSync(folder).isDirectory()) {
81
- return fs.readdirSync(folder, "utf8")
82
- .filter(file => extensionsList.has(path.extname(file)))
83
- .map(file => {
84
- return createVirtualPage({
85
- file: path.join(folder, file),
86
- frontmatter: fm,
87
- transform: transform,
88
- extname: ".html",
89
- });
278
+ // @description markdown plugin
279
+ // @params {Object} options options for this plugin
280
+ // @params {Object} options.parser markdown parser (for example marked.parse)
281
+ const MarkdownPlugin = (options = {}) => {
282
+ return {
283
+ name: "MarkdownPlugin",
284
+ transform: (_, node) => {
285
+ if (path.extname(node.path) === ".md" || path.extname(node.path) === ".markdown") {
286
+ // const marked = new Marked(options);
287
+ // getPlugins(context.plugins, "markdownPlugins").forEach(plugin => {
288
+ // (plugin.markdownPlugins(context, node) || []).forEach(markedPlugin => {
289
+ // marked.use(markedPlugin);
290
+ // });
291
+ // });
292
+ node.data.content = options.parser(node.data.content);
293
+ node.data.path = utils.format(node.data.path, {extname: ".html"});
294
+ }
295
+ },
296
+ };
297
+ };
298
+
299
+ // @description content plugin
300
+ const ContentPlugin = (options = {}) => {
301
+ const label = options.label || "asset/layout";
302
+ const extensions = options.extensions || [".html", ".md", ".markdown"];
303
+ return {
304
+ name: "ContentPlugin",
305
+ load: context => {
306
+ return createNode(context.source, context.config.layout || options.layout, label);
307
+ },
308
+ transform: (_, node) => {
309
+ if (node.label === label) {
310
+ node.data.content = utils.read(path.join(node.source, node.path));
311
+ }
312
+ },
313
+ getDependencyGraph: context => {
314
+ const graph = [];
315
+ const template = getNodesByLabel(context.nodes, label)[0];
316
+ context.nodes.forEach(node => {
317
+ if (node.label !== label && extensions.includes(path.extname(node.path))) {
318
+ graph.push([
319
+ path.join(template.source, template.path),
320
+ path.join(node.source, node.path),
321
+ ]);
322
+ }
90
323
  });
91
- }
92
- return [];
93
- };
94
-
95
- // @description get assets
96
- const readAssets = (folder, fm = null) => {
97
- if (fs.existsSync(folder) && fs.lstatSync(folder).isDirectory()) {
98
- const assetPaths = fs.readdirSync(folder, "utf8");
99
- return Object.fromEntries(assetPaths.map(file => {
100
- const asset = createVirtualPage({
101
- file: path.join(folder, file),
102
- frontmatter: fm,
324
+ return graph;
325
+ },
326
+ shouldEmit: (_, node) => {
327
+ return node.label !== label;
328
+ },
329
+ emit: (context, nodesToEmit) => {
330
+ // prepare site data
331
+ const siteData = Object.assign({}, context.config, {
332
+ data: Object.fromEntries(getNodesByLabel(context.nodes, "asset/data").map(node => {
333
+ return [node.data.name, node.data.content];
334
+ })),
335
+ pages: getNodesByLabel(context.nodes, "pages").map(n => n.data),
336
+ posts: getNodesByLabel(context.nodes, "posts").map(n => n.data),
103
337
  });
104
- const assetName = asset.basename.replaceAll(".", "_").replaceAll("-", "_");
105
- return [assetName, asset];
106
- }));
107
- }
108
- return {};
109
- };
110
-
111
- // @description read a data folder
112
- const readData = folder => {
113
- if (fs.existsSync(folder) && fs.lstatSync(folder).isDirectory()) {
114
- const files = fs.readdirSync(folder, "utf8")
115
- .filter(file => path.extname(file) === ".json")
116
- .map(file => path.join(folder, file))
117
- .map(file => {
118
- return [path.basename(file, ".json"), JSON.parse(fs.readFileSync(file, "utf8"))];
338
+ // get data files
339
+ const template = getNodesByLabel(context.nodes, label)[0];
340
+ const compiler = mikel.create(template.data.content, options);
341
+ nodesToEmit.forEach(node => {
342
+ if (extensions.includes(path.extname(node.path))) {
343
+ compiler.addPartial("content", node.data.content);
344
+ const content = compiler({
345
+ site: siteData,
346
+ page: node.data,
347
+ layout: template.data,
348
+ });
349
+ // const filePath = utils.format(node.data.path || node.path, {extname: ".html"});
350
+ const filePath = node.data?.path || node.path;
351
+ utils.write(path.join(context.destination, filePath), content);
352
+ }
119
353
  });
120
- return Object.fromEntries(files);
121
- }
122
- return {};
123
- };
124
-
125
- // @description run mikel press with the provided configuration
126
- const run = (config = {}) => {
127
- // 0. initialize context object
128
- const hooks = ["initialize", "compiler", "beforeEmit", "emitPage", "emitAsset", "done"];
129
- const context = {
130
- site: config || {},
131
- source: path.resolve(process.cwd(), config?.source || "."),
132
- destination: path.resolve(process.cwd(), config?.destination || "./www"),
133
- layout: getLayoutContent(config),
134
- hooks: Object.freeze(Object.fromEntries(hooks.map(name => {
135
- return [name, new Set()];
136
- }))),
354
+ },
137
355
  };
138
- const dispatch = (name, args) => Array.from(context.hooks[name]).forEach(fn => fn.apply(null, args));
139
- // 1. execute plugins
140
- if (config?.plugins && Array.isArray(config?.plugins)) {
141
- config.plugins.forEach(plugin => plugin(context));
142
- }
143
- dispatch("initialize", []);
144
- // 2. initialize mikel instance
145
- const compiler = mikel.create(context.layout.content, config?.mikel || {});
146
- dispatch("compiler", [context.compiler]);
147
- // 3. read stuff
148
- context.site.data = readData(path.join(context.source, config?.dataDir || "data"));
149
- context.site.pages = readPages(path.join(context.source, config?.pagesDir || "pages"), ".html", config?.frontmatter, c => c);
150
- context.site.assets = readAssets(path.join(context.source, config?.assetsDir || "assets"), config?.frontmatter);
151
- dispatch("beforeEmit", []);
152
- // 4. save pages
153
- context.site.pages.forEach(page => {
154
- compiler.addPartial("content", page.content); // register page content as partial
155
- const content = compiler({
156
- site: context.site,
157
- layout: context.layout,
158
- page: page,
159
- });
160
- dispatch("emitPage", [page, content]);
161
- saveFile(path.join(context.destination, page.url), content);
162
- });
163
- // 5. save assets
164
- Object.values(context.site.assets).forEach(asset => {
165
- dispatch("emitAsset", [asset]);
166
- saveFile(path.join(context.destination, asset.url), asset.content);
167
- });
168
- dispatch("done", []);
169
356
  };
170
357
 
171
- // plugin to read and include posts in markdown
172
- const postsPlugin = (options = {}) => {
173
- return context => {
174
- context.hooks.beforeEmit.add(() => {
175
- const posts = readPages(path.join(context.source, options?.dir || "posts"), [".md"], context.site.frontmatter, options?.parser);
176
- context.site.posts = posts; // posts will be accesible in site.posts
177
- context.site.pages = [...context.site.pages, ...posts]; // posts will be included as pages also
178
- });
358
+ // @description copy plugin
359
+ const CopyAssetsPlugin = (options = {}) => {
360
+ return {
361
+ name: "CopyAssetsPlugin",
362
+ emit: context => {
363
+ (options.patterns || []).forEach(item => {
364
+ if (item.from && item.to && fs.existsSync(item.from)) {
365
+ utils.copy(item.from, path.join(context.destination, item.to));
366
+ }
367
+ });
368
+ },
179
369
  };
180
370
  };
181
371
 
182
- // progress plugin
183
- const progressPlugin = () => {
184
- return context => {
185
- const timeStart = Date.now();
186
- const log = (status, msg) => console.log(`[${new Date().toISOString()}] (${status}) ${msg}`);
187
- context.hooks.initialize.add(() => {
188
- log("info", `source directory: ${context.source}`);
189
- log("info", `destination directory: ${context.destination}`);
190
- });
191
- context.hooks.emitPage.add(page => {
192
- log("info", `saving page: ${page.url} --> ${path.join(context.destination, page.url)}`);
193
- });
194
- context.hooks.emitAsset.add(asset => {
195
- log("info", `saving asset: ${asset.url} --> ${path.join(context.destination, asset.url)}`);
196
- });
197
- context.hooks.done.add(() => {
198
- log("done", `build completed in ${Date.now() - timeStart}ms`);
199
- });
200
- };
372
+ // @description default export of mikel-press
373
+ export default {
374
+ // @description run mikel-press and generate the static site
375
+ // @param {Object} config configuration object
376
+ build: config => {
377
+ buildContext(createContext(config));
378
+ },
379
+ // @description watch for changes in the source folder and rebuild the site
380
+ // @param {Object} config configuration object
381
+ watch: config => {
382
+ const context = createContext(config);
383
+ buildContext(context, context.nodes);
384
+ watchContext(context);
385
+ },
386
+ // utilities for working with files
387
+ utils: utils,
388
+ // helpers for working with the context
389
+ createNode: createNode,
390
+ createContext: createContext,
391
+ buildContext: buildContext,
392
+ watchContext: watchContext,
393
+ // plugins
394
+ SourcePlugin: SourcePlugin,
395
+ DataPlugin: DataPlugin,
396
+ MarkdownPlugin: MarkdownPlugin,
397
+ FrontmatterPlugin: FrontmatterPlugin,
398
+ PermalinkPlugin: PermalinkPlugin,
399
+ ContentPlugin: ContentPlugin,
400
+ CopyAssetsPlugin: CopyAssetsPlugin,
201
401
  };
202
-
203
- // export
204
- export default {run, createVirtualPage, frontmatter, postsPlugin, progressPlugin};
package/package.json CHANGED
@@ -1,40 +1,36 @@
1
1
  {
2
2
  "name": "mikel-press",
3
- "description": "A minimal static site generator based on mikel templating",
4
- "version": "0.2.0",
3
+ "description": "A tiny and fast static site generator based on mikel templating",
4
+ "version": "0.18.1",
5
5
  "type": "module",
6
+ "license": "MIT",
6
7
  "author": {
7
8
  "name": "Josemi Juanes",
8
9
  "email": "hello@josemi.xyz"
9
10
  },
10
- "license": "MIT",
11
11
  "repository": "https://github.com/jmjuanes/mikel",
12
12
  "bugs": "https://github.com/jmjuanes/mikel/issues",
13
- "main": "index.js",
14
- "module": "index.js",
15
- "types": "index.d.ts",
16
13
  "exports": {
17
14
  ".": "./index.js",
18
15
  "./index.js": "./index.js",
19
16
  "./package.json": "./package.json"
20
17
  },
21
- "scripts": {
22
- "test": "node test.js"
18
+ "engines": {
19
+ "node": "20 || >=22"
23
20
  },
24
- "keywords": [
25
- "static",
26
- "site",
27
- "generator",
28
- "mikel",
29
- "template",
30
- "templating"
31
- ],
32
21
  "dependencies": {
33
- "mikel": "^0.17.0"
22
+ "mikel": "^0.18.1"
34
23
  },
35
24
  "files": [
36
25
  "README.md",
37
- "index.js",
38
- "index.d.ts"
26
+ "index.js"
27
+ ],
28
+ "keywords": [
29
+ "static-site",
30
+ "site-generator",
31
+ "digital-garden",
32
+ "markdown",
33
+ "blog",
34
+ "mikel"
39
35
  ]
40
- }
36
+ }
package/index.d.ts DELETED
@@ -1,55 +0,0 @@
1
- interface MikelTemplateOptions {
2
- functions: {[key: string]: (args: any) => string};
3
- helpers: {[key: string]: (args: any) => string};
4
- partials: {[key: string]: string};
5
- }
6
-
7
- interface FrontmatterResult {
8
- body: string;
9
- attributes: any;
10
- }
11
-
12
- interface VirtualPageOptions {
13
- content?: string;
14
- file?: string;
15
- extname?: string;
16
- basename?: string;
17
- frontmatter?: (str: string) => FrontmatterResult;
18
- transform?: (str: string) => string;
19
- }
20
-
21
- interface VirtualPage {
22
- content: string;
23
- attributes: any;
24
- name: string;
25
- extname: string;
26
- basename: string;
27
- url: string;
28
- }
29
-
30
- interface PostsPluginOptions {
31
- dir: string;
32
- parser: (str: string) => string;
33
- }
34
-
35
- interface SiteConfig {
36
- source: string;
37
- destination: string;
38
- layout: string;
39
- layoutContent: string;
40
- dataDir: string;
41
- pagesDir: string;
42
- assetsDir: string;
43
- frontmatter: (str: string) => FrontmatterResult;
44
- mikel: Partial<MikelTemplateOptions>;
45
- plugins: any[];
46
- }
47
-
48
- declare module "mikel-press" {
49
- export function frontmatter(str: string): FrontmatterResult;
50
- export function createVirtualPage(options: VirtualPageOptions): VirtualPage;
51
- export function run(config: Partial<SiteConfig>): void;
52
-
53
- export function postsPlugin(options: PostsPluginOptions): any;
54
- export function progressPlugin(): any;
55
- }