mikel-press 0.2.0 → 0.18.2

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 +438 -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
@@ -1,204 +1,462 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import * as http from "node:http";
3
4
  import mikel from "mikel";
4
5
 
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)];
6
+ // @description default mime types
7
+ const DEFAULT_MIME_TYPES = {
8
+ ".css": "text/css",
9
+ ".gif": "image/gif",
10
+ ".html": "text/html",
11
+ ".ico": "image/vnd.microsoft.icon",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".js": "text/javascript",
15
+ ".json": "application/json",
16
+ ".mjs": "text/javascript",
17
+ ".png": "image/png",
18
+ ".svg": "image/svg+xml",
19
+ ".txt": "text/plain",
20
+ };
21
+
22
+ // @description general utilities
23
+ const utils = {
24
+ // @description read a file from disk
25
+ // @param {String} file path to the file to read
26
+ read: (file, encoding = "utf8") => {
27
+ return fs.readFileSync(file, encoding);
28
+ },
29
+ // @description write a file to disk
30
+ // @param {String} file path to the file to save
31
+ // @param {String} content content to save
32
+ write: (file, content = "") => {
33
+ const folder = path.dirname(file);
34
+ if (!fs.existsSync(folder)) {
35
+ fs.mkdirSync(folder, {recursive: true});
12
36
  }
13
- if (value === "true" || value === "false" || value === "null") {
14
- return [key, JSON.parse(value)];
37
+ fs.writeFileSync(file, content, "utf8");
38
+ },
39
+ // @description copy a file
40
+ copy: (source, target) => {
41
+ const folder = path.dirname(target);
42
+ if (!fs.existsSync(folder)) {
43
+ fs.mkdirSync(folder, {recursive: true});
15
44
  }
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: {}};
53
- return {
54
- content: body,
55
- data: attributes || {},
56
- attributes: attributes || {},
57
- };
45
+ fs.copyFileSync(source, target);
46
+ },
47
+ // @description get all files from the given folder and the given extensions
48
+ readdir: (folder, extensions = "*") => {
49
+ if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
50
+ return [];
51
+ }
52
+ return fs.readdirSync(folder, "utf8").filter(file => {
53
+ return extensions === "*" || extensions.includes(path.extname(file));
54
+ });
55
+ },
56
+ // @description walk through the given folder and get all files
57
+ // @params {String} folder folder to walk through
58
+ // @params {Array|String} extensions extensions to include. Default: "*"
59
+ walkdir: (folder, extensions = "*") => {
60
+ const files = [];
61
+ const walkSync = currentFolder => {
62
+ const fullFolderPath = path.join(folder, currentFolder);
63
+ fs.readdirSync(fullFolderPath).forEach(file => {
64
+ const filePath = path.join(currentFolder, file);
65
+ const fullFilePath = path.join(fullFolderPath, file);
66
+ if (fs.statSync(fullFilePath).isDirectory()) {
67
+ return walkSync(filePath);
68
+ }
69
+ if (extensions === "*" || extensions.includes(path.extname(file))) {
70
+ files.push(filePath);
71
+ }
72
+ });
73
+ };
74
+ walkSync("./");
75
+ return files;
76
+ },
77
+ // @description watch for file changes
78
+ // @param {String} filePath path to the file to watch
79
+ // @param {Function} listener method to listen for file changes
80
+ watch: (filePath, listener) => {
81
+ let lastModifiedTime = null;
82
+ fs.watch(filePath, "utf8", () => {
83
+ const modifiedTime = fs.statSync(filePath).mtimeMs;
84
+ if (lastModifiedTime !== modifiedTime) {
85
+ lastModifiedTime = modifiedTime;
86
+ return listener(filePath);
87
+ }
88
+ });
89
+ },
90
+ // @description change the properties of the given path (dirname, basename, extname)
91
+ format: (filePath, options = {}) => {
92
+ const dirname = options.dirname || path.dirname(filePath);
93
+ const extname = options.extname || path.extname(filePath);
94
+ const basename = options.basename || path.basename(filePath, path.extname(filePath));
95
+ return path.join(dirname, `${basename}${extname}`);
96
+ },
97
+ // @description get the mime type from the given extension
98
+ getMimeType: (extname = ".txt") => {
99
+ return DEFAULT_MIME_TYPES[extname] || "text/plain";
100
+ },
58
101
  };
59
102
 
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: {}};
66
- 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,
74
- };
103
+ // @description add a new node item
104
+ const createNode = (source, path, label = "", data = {}) => {
105
+ return {source, path, label, data};
75
106
  };
76
107
 
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
- });
90
- });
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,
103
- });
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"))];
119
- });
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
- }))),
108
+ // @description get nodes with the specified label
109
+ const getNodesByLabel = (nodes, label) => {
110
+ return Array.from(nodes).filter(node => node.label === label);
111
+ };
112
+
113
+ // @description get all nodes to update
114
+ const getNodesToUpdate = (graph, affectedNode) => {
115
+ const listOfAffectedNodes = new Set();
116
+ const walkNodes = currentNode => {
117
+ listOfAffectedNodes.add(currentNode);
118
+ return graph.forEach(edge => {
119
+ if (edge[0] === currentNode && !listOfAffectedNodes.has(edge[1])) {
120
+ walkNodes(edge[1]);
121
+ }
122
+ });
137
123
  };
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,
124
+ walkNodes(affectedNode);
125
+ return listOfAffectedNodes;
126
+ };
127
+
128
+ // @description get plugins with the specified function
129
+ const getPlugins = (plugins, functionName) => {
130
+ return plugins.filter(plugin => typeof plugin[functionName] === "function");
131
+ };
132
+
133
+ // create a new context from the provided configuration
134
+ const createContext = config => {
135
+ const {source, destination, plugins, ...otherConfiguration} = config;
136
+ const context = Object.freeze({
137
+ config: otherConfiguration,
138
+ source: path.resolve(source || "."),
139
+ destination: path.resolve(destination || "./www"),
140
+ plugins: plugins || [],
141
+ nodes: [],
142
+ edges: [],
143
+ });
144
+ // load nodes into context
145
+ const nodesPaths = new Set(); // prevent adding duplicated nodes
146
+ getPlugins(context.plugins, "load").forEach(plugin => {
147
+ const nodes = plugin.load(context) || [];
148
+ [nodes].flat().forEach(node => {
149
+ const nodeFullPath = path.join(node.source, node.path);
150
+ if (nodesPaths.has(nodeFullPath)) {
151
+ throw new Error(`File ${nodeFullPath} has been already processed by another plugin`);
152
+ }
153
+ context.nodes.push(node);
154
+ nodesPaths.add(nodeFullPath);
159
155
  });
160
- dispatch("emitPage", [page, content]);
161
- saveFile(path.join(context.destination, page.url), content);
162
156
  });
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);
157
+ // generate dependency graph
158
+ const edgesPaths = new Set(); // prevent adding duplicated edges
159
+ getPlugins(context.plugins, "getDependencyGraph").forEach(plugin => {
160
+ (plugin.getDependencyGraph(context) || []).forEach(edge => {
161
+ if (!edge.every(node => nodesPaths.has(node))) {
162
+ throw new Error(`Dependency graph contains nodes that have not been loaded`);
163
+ }
164
+ const edgePath = edge.join(" -> ");
165
+ if (!edgesPaths.has(edgePath)) {
166
+ context.edges.push(edge);
167
+ edgesPaths.add(edgePath);
168
+ }
169
+ });
167
170
  });
168
- dispatch("done", []);
171
+ // return context
172
+ return context;
169
173
  };
170
174
 
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
175
+ // @description build context
176
+ const buildContext = (context, nodes = null) => {
177
+ const nodesToBuild = (nodes && Array.isArray(nodes)) ? nodes : context.nodes;
178
+ // reset nodes path
179
+ nodesToBuild.forEach(node => {
180
+ node.data.path = node.path;
181
+ });
182
+ // transform nodes
183
+ const transformPlugins = getPlugins(context.plugins, "transform");
184
+ nodesToBuild.forEach((node, _, allNodes) => {
185
+ transformPlugins.forEach(plugin => {
186
+ return plugin.transform(context, node, allNodes);
178
187
  });
179
- };
188
+ });
189
+ // filter nodes and get only the ones that are going to be emitted
190
+ const shouldEmitPlugins = getPlugins(context.plugins, "shouldEmit");
191
+ const filteredNodes = nodesToBuild.filter((node, _, allNodes) => {
192
+ for (let i = 0; i < shouldEmitPlugins.length; i++) {
193
+ const plugin = shouldEmitPlugins[i];
194
+ if (!plugin.shouldEmit(context, node, allNodes)) {
195
+ return false;
196
+ }
197
+ }
198
+ return true;
199
+ });
200
+ // emit each node
201
+ getPlugins(context.plugins, "emit").forEach(plugin => {
202
+ return plugin.emit(context, filteredNodes);
203
+ });
180
204
  };
181
205
 
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)}`);
206
+ // @description start a watch on the current context
207
+ const watchContext = context => {
208
+ // force to rebuild
209
+ const rebuild = changedNodePath => {
210
+ const nodesPathsToBuild = getNodesToUpdate(context.edges, changedNodePath);
211
+ const nodesToRebuild = context.nodes.filter(node => {
212
+ return nodesPathsToBuild.has(path.join(node.source, node.path));
196
213
  });
197
- context.hooks.done.add(() => {
198
- log("done", `build completed in ${Date.now() - timeStart}ms`);
214
+ // perform the rebuild of the context
215
+ buildContext(context, nodesToRebuild);
216
+ };
217
+ // create a watch for each registered node in the context
218
+ context.nodes.forEach(node => {
219
+ return utils.watch(path.join(node.source, node.path), rebuild);
220
+ });
221
+ };
222
+
223
+ // @description start a server on the current context
224
+ // @param {Object} context current site context
225
+ // @param {Object} options server options
226
+ // @param {String} options.port port that the server will listen. Default: "3000"
227
+ // @param {Function} options.getMimeType function to obtain the associated mime type from the given extension
228
+ const serveContext = (context, options = {}) => {
229
+ const port = parseInt(options?.port || "3000");
230
+ const getMimeType = options?.getMimeType || utils.getMimeType;
231
+ const server = http.createServer((request, response) => {
232
+ let responseCode = 200;
233
+ let url = path.join(context.destination, path.normalize(request.url));
234
+ // check for directory
235
+ if (url.endsWith("/") || (fs.existsSync(url) && fs.statSync(url).isDirectory())) {
236
+ url = path.join(url, "index.html");
237
+ }
238
+ // check if we have to append the '.html' extension
239
+ if (!fs.existsSync(url) && fs.existsSync(url + ".html")) {
240
+ url = url + ".html";
241
+ }
242
+ // check if the file does not exist
243
+ if (!fs.existsSync(url)) {
244
+ url = path.join(context.destination, "404.html");
245
+ responseCode = 404;
246
+ }
247
+ // send the file
248
+ response.writeHead(responseCode, {
249
+ "Content-Type": getMimeType?.(path.extname(url)) || "text/plain",
199
250
  });
251
+ fs.createReadStream(url).pipe(response);
252
+ console.log(`[${responseCode}] ${request.method} ${request.url}`);
253
+ });
254
+ // launch server
255
+ server.listen(port);
256
+ console.log(`Server running at http://127.0.0.1:${port}/`);
257
+ };
258
+
259
+ // @description source plugin
260
+ const SourcePlugin = (options = {}) => {
261
+ const label = options.label || "pages";
262
+ return {
263
+ name: "SourcePlugin",
264
+ load: context => {
265
+ const folder = path.resolve(context.source, options.source || "./content");
266
+ const nodes = utils.walkdir(folder, options?.extensions || "*").map(file => {
267
+ return createNode(folder, file, label);
268
+ });
269
+ return nodes;
270
+ },
271
+ transform: (_, node) => {
272
+ if (node.label === label) {
273
+ node.data.content = utils.read(path.join(node.source, node.path));
274
+ }
275
+ },
276
+ };
277
+ };
278
+
279
+ // @description data plugin
280
+ const DataPlugin = (options = {}) => {
281
+ const label = options?.label || "asset/data";
282
+ return {
283
+ name: "DataPlugin",
284
+ load: context => {
285
+ const folder = path.resolve(context.source, options.source || "./data");
286
+ return utils.readdir(folder, [".json"]).map(file => {
287
+ return createNode(folder, file, label);
288
+ });
289
+ },
290
+ transform: (_, node) => {
291
+ if (node.label === label && path.extname(node.path) === ".json") {
292
+ node.data.name = path.basename(node.path, ".json");
293
+ node.data.content = JSON.parse(utils.read(path.join(node.source, node.path)));
294
+ }
295
+ },
296
+ shouldEmit: (_, node) => {
297
+ return node.label !== label;
298
+ },
299
+ };
300
+ };
301
+
302
+ // @description frontmatter plugin
303
+ // @params {Object} options options for this plugin
304
+ // @params {Array} options.extensions extensions to process. Default: [".md", ".markdown", ".html"]
305
+ // @params {Function} options.parser frontmatter parser (JSON.parse, YAML.load)
306
+ const FrontmatterPlugin = (options = {}) => {
307
+ const extensions = options.extensions || [".md", ".markdown", ".html"];
308
+ return {
309
+ name: "FrontmatterPlugin",
310
+ transform: (_, node) => {
311
+ if ((extensions === "*" || extensions.includes(path.extname(node.path))) && typeof node.data.content === "string") {
312
+ node.data.attributes = {};
313
+ const matches = Array.from(node.data.content.matchAll(/^(--- *)/gm))
314
+ if (matches?.length === 2 && matches[0].index === 0) {
315
+ const front = node.data.content.substring(0 + matches[0][1].length, matches[1].index).trim();
316
+ node.data.content = node.data.content.substring(matches[1].index + matches[1][1].length).trim();
317
+ if (typeof options.parser === "function") {
318
+ node.data.attributes = options.parser(front);
319
+ }
320
+ }
321
+ }
322
+ },
323
+ };
324
+ };
325
+
326
+ // @description permalink plugin
327
+ const PermalinkPlugin = () => {
328
+ return {
329
+ name: "PermalinkPlugin",
330
+ transform: (_, node) => {
331
+ node.data.path = node.data?.attributes?.permalink || node.data.path;
332
+ // node.data.url = path.normalize("/" + node.data.path);
333
+ },
334
+ };
335
+ };
336
+
337
+ // @description markdown plugin
338
+ // @params {Object} options options for this plugin
339
+ // @params {Object} options.parser markdown parser (for example marked.parse)
340
+ const MarkdownPlugin = (options = {}) => {
341
+ return {
342
+ name: "MarkdownPlugin",
343
+ transform: (_, node) => {
344
+ if (path.extname(node.path) === ".md" || path.extname(node.path) === ".markdown") {
345
+ // const marked = new Marked(options);
346
+ // getPlugins(context.plugins, "markdownPlugins").forEach(plugin => {
347
+ // (plugin.markdownPlugins(context, node) || []).forEach(markedPlugin => {
348
+ // marked.use(markedPlugin);
349
+ // });
350
+ // });
351
+ node.data.content = options.parser(node.data.content);
352
+ node.data.path = utils.format(node.data.path, {extname: ".html"});
353
+ }
354
+ },
355
+ };
356
+ };
357
+
358
+ // @description content plugin
359
+ const ContentPlugin = (options = {}) => {
360
+ const label = options.label || "asset/layout";
361
+ const extensions = options.extensions || [".html", ".md", ".markdown"];
362
+ return {
363
+ name: "ContentPlugin",
364
+ load: context => {
365
+ const layoutPath = path.resolve(context.source, context.config.layout || options.layout);
366
+ return createNode(path.dirname(layoutPath), path.basename(layoutPath), label);
367
+ },
368
+ transform: (_, node) => {
369
+ if (node.label === label) {
370
+ node.data.content = utils.read(path.join(node.source, node.path));
371
+ }
372
+ },
373
+ getDependencyGraph: context => {
374
+ const graph = [];
375
+ const template = getNodesByLabel(context.nodes, label)[0];
376
+ context.nodes.forEach(node => {
377
+ if (node.label !== label && extensions.includes(path.extname(node.path))) {
378
+ graph.push([
379
+ path.join(template.source, template.path),
380
+ path.join(node.source, node.path),
381
+ ]);
382
+ }
383
+ });
384
+ return graph;
385
+ },
386
+ shouldEmit: (_, node) => {
387
+ return node.label !== label;
388
+ },
389
+ emit: (context, nodesToEmit) => {
390
+ // prepare site data
391
+ const siteData = Object.assign({}, context.config, {
392
+ data: Object.fromEntries(getNodesByLabel(context.nodes, "asset/data").map(node => {
393
+ return [node.data.name, node.data.content];
394
+ })),
395
+ pages: getNodesByLabel(context.nodes, "pages").map(n => n.data),
396
+ posts: getNodesByLabel(context.nodes, "posts").map(n => n.data),
397
+ });
398
+ // get data files
399
+ const template = getNodesByLabel(context.nodes, label)[0];
400
+ const compiler = mikel.create(template.data.content, options);
401
+ nodesToEmit.forEach(node => {
402
+ if (extensions.includes(path.extname(node.path))) {
403
+ compiler.addPartial("content", node.data.content);
404
+ const content = compiler({
405
+ site: siteData,
406
+ page: node.data,
407
+ layout: template.data,
408
+ });
409
+ // const filePath = utils.format(node.data.path || node.path, {extname: ".html"});
410
+ const filePath = node.data?.path || node.path;
411
+ utils.write(path.join(context.destination, filePath), content);
412
+ }
413
+ });
414
+ },
200
415
  };
201
416
  };
202
417
 
203
- // export
204
- export default {run, createVirtualPage, frontmatter, postsPlugin, progressPlugin};
418
+ // @description copy plugin
419
+ const CopyAssetsPlugin = (options = {}) => {
420
+ return {
421
+ name: "CopyAssetsPlugin",
422
+ emit: context => {
423
+ (options.patterns || []).forEach(item => {
424
+ if (item.from && item.to && fs.existsSync(item.from)) {
425
+ utils.copy(item.from, path.join(context.destination, item.to));
426
+ }
427
+ });
428
+ },
429
+ };
430
+ };
431
+
432
+ // @description default export of mikel-press
433
+ export default {
434
+ // @description run mikel-press and generate the static site
435
+ // @param {Object} config configuration object
436
+ build: config => {
437
+ buildContext(createContext(config));
438
+ },
439
+ // @description watch for changes in the source folder and rebuild the site
440
+ // @param {Object} config configuration object
441
+ watch: config => {
442
+ const context = createContext(config);
443
+ buildContext(context, context.nodes);
444
+ watchContext(context);
445
+ },
446
+ // utilities for working with files
447
+ utils: utils,
448
+ // helpers for working with the context
449
+ createNode: createNode,
450
+ createContext: createContext,
451
+ buildContext: buildContext,
452
+ watchContext: watchContext,
453
+ serveContext: serveContext,
454
+ // plugins
455
+ SourcePlugin: SourcePlugin,
456
+ DataPlugin: DataPlugin,
457
+ MarkdownPlugin: MarkdownPlugin,
458
+ FrontmatterPlugin: FrontmatterPlugin,
459
+ PermalinkPlugin: PermalinkPlugin,
460
+ ContentPlugin: ContentPlugin,
461
+ CopyAssetsPlugin: CopyAssetsPlugin,
462
+ };
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.2",
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"
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.2"
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
- }