A tasty little Markdown to anything generator with very few opinions.
Why is it named
ragu
? Because it's a family favorite sauce. 🍝
Take some Markdown, cook it into Ragu. Add your own pasta and you've got a delicious dish!
- Ragù alla bolognese: a normal Markdown to HTML recipe, kind of like 11ty or Jekyll.
- ...more to come!
The usual ways:
npm install ragu
# or as a global CLI tool:
npm install -g ragu
# you don't need to install
npx ragu -c
Ragu does not have very many opinions cooked in, it just ingests Markdown files in a particular order, so before you can build your site you'll need to make a recipe.
You do that by creating a file ragu.config.js
which exports a default options object:
export default {
input: './content',
output: './public',
// ... and lots of other options
}
If you want to use a pre-cooked recipe, try ragù alla bolognese:
// ragu.config.js
import { config } from 'bolognese'
// re-export as default
export { config as default }
You just run ragu
to build, or ragu [input] [output]
where the [input]
is your content folder and [output]
is where to put everything.
ragu /path/to/content /path/to/build
Your config file can define the input
and output
folders:
# if the config file is in the current directory
ragu # by itself
# if the file has a different name
ragu --config spicy.config.js # or -c
# if the config file is elsewhere
ragu -c /path/to/ragu.config.js
Ragu also does not have any file watcher built in. If you want that, you'll need to add it, probably as a dev-dependency in your project.
Here's a way you could do that, with the chokidar-cli
library:
chokidar "./ragu.config.js" "./content/**/*.md" -c "ragu"
Or as a run-script in your package.json
:
{
"scripts": {
"build": "ragu -c ./path/to/ragu.config.js",
"watch": "chokidar './path/to/ragu.config.js' './path/to/content/**/*.md' -c 'npm run build'"
}
}
Ragu also doesn't have a server cooked in, but you can specify that in your config file.
In fact, most things are defined in the config file, so there aren't many CLI options.
Understanding how Ragu works will help you see how it might differ from similar software.
When you build, the following steps are followed:
The first action is to scan the input directory for files.
By default Ragu looks for all .md
files, but you can pass in a filter function:
// ragu.config.js
import { extname } = 'node:path'
const EXTENSIONS = [ '.md', '.txt' ]
export default {
// ... other stuff, then ...
filter: file => EXTENSIONS.includes(extname(file)),
}
The filtered files are read using a read stream to extract and parse the frontmatter/metadata and content sections.
You don't need to provide a read function, by default Ragu will try to read files using the common triple dash separation for the frontmatter section, like this:
---
title: My Cool Blog Post
published: true
---
Many wise words.
If you use the non-fenced version, or some other syntax, you'll need to provide a read
function in your configuration. For example, if you are using blockdown syntax you might do this:
// ragu.config.js
import { parse } from '@saibotsivad/blockdown'
export default {
// ... other stuff, then ...
read: ({ stream, filepath, callback }) => {
let file = ''
stream.on('data', data => { file += data })
stream.on('end', () => {
const { blocks } = parse(file)
callback(
false, // error-first: there is no error
{
frontmatter: blocks[0].content,
// The `content` can be anything, a string or a list, or
// whatever you want. It'll get passed as-is to the renderer in
// later steps, which will need to understand the structure.
// In this example, it is an array of objects.
content: blocks.slice(1),
},
)
})
},
}
The read
function is called with an object containing the following properties:
callback: function
- The error-first function to call when you've read the file enough, typically on the streamend
event unless you can short-circuit and exit early.filepath: string
- The filepath of the content file, relative to the input directory, e.g. ifinput
were./content
this might bearticles/how-to-drive.md
.stream: ReadStream
- The NodeJS read stream for the file.
When you're done reading the file, either because you've reached the end or detected that it's not a valid content file, call the error-first callback
function with an object as the second variable, containing these properties:
frontmatter: string
optional - The extracted string, exactly as you would pass it to the frontmatter parser.content: any
optional - The extracted content, in any form. This property will be passed exactly as-is to the later render step.ignore: boolean
optional - If this is set totrue
, this file will not be passed to later steps.
Note: Be sure to call
stream.close()
if you can close the stream early, e.g. if you can detect that it is not a valid content file.
The frontmatter string is passed through a parser to become the per-file metadata object passed to later steps.
Note: Ragu does not have an opinion on metadata parsers! If you don't provide one, it won't be called.
The most popular parser is probably js-yaml, which would look like this when configured (there are many options for parsing YAML):
// ragu.config.js
import yaml from 'js-yaml'
export default {
// ... other stuff, then ...
parse: ({ frontmatter, filepath }) => {
const metadata = yaml.load(string)
// do some normalization here as needed
return { metadata }
},
}
The function is called with an object containing the following properties:
filepath: string
- The filepath of the content file, relative to the input directory, e.g. ifinput
were./content
this might bearticles/how-to-drive.md
.frontmatter: string
- The extracted frontmatter section of the file, if successfully parsed.
This function is also where you might do some normalization of metadata properties. For example, it's common that the published
property is either boolean or a date string. In the parse
function you might normalize to boolean, based on the current date:
// ragu.config.js
import yaml from 'js-yaml'
const now = Date.now()
export default {
// ... other stuff, then ...
parse: ({ frontmatter, filepath }) => {
const metadata = yaml.load(string)
metadata.published = metadata.published === true
|| metadata.published.getTime() > now
return { metadata }
},
}
The function can also be asynchronous, and should return the following properties:
metadata: Any
- The metadata as parsed by your configured parser.ignore: Boolean
optional - If set to true, the metadata will not be used in the next step.
After the frontmatter for all files is parsed, the resulting metadata for all files is passed to a merge function as a map of filename to metadata.
The output of this is passed to the render function in the next step, so here is where you would likely want to create things like collections, e.g. for "tags", "authors", and so on.
Note: Ragu has no opinions baked in here: if you don't provide this function, the property given to the renderer will be
undefined
!
Here's an example of a merging function that you might be likely to use.
Note: In this example, the
site.js
file is not a Ragu feature. Although it is a wise organizational strategy to move constant properties out to other files, Ragu does not auto-magically pull in data files, like you might see in_data/site.yaml
for Jekyll or others.
// site.js
export default {
baseUrl: 'https://site.com',
}
// ragu.config.js
import sitewideProperties from './site.js'
export default {
// ... other stuff, then ...
merge: ({ filepathToMetadata }) => {
const merged = {
...sitewideProperties, // in this example, this adds `baseUrl`
authors: new Set(),
tags: new Set(),
rss: []
}
for (const filename in filepathToMetadata) {
const { author, tags, published } = filepathToMetadata[filename] || {}
if (author) merged.authors.add(author)
if (tags?.length) for (const tag of tags) merged.tags.add(tag)
if (published) merged.rss.push(filename)
}
return merged
},
}
The function is called with an object containing the following property:
filepathToMetadata: Object
- This is a map the key is each filepath (the path relative to theinput
folder) and the value is whatever came out of themerge
function in Step 3.
The merging function can be async
, for example if you need to do any pre-render setup work based on the merged metadata.
After all metadata is merged, a render function is called for each file.
The function is called with an options object and an error-first callback function.
The options object contains the following properties:
content: any
- This is the exact property given from Step 2. If you are using normal Markdown, this would likely be the fully-realizedstring
of the file, with the frontmatter/metadata section removed.filepath: string
- The filepath of the content file, relative to the input directory, e.g. ifinput
were./content
this might bearticles/how-to-drive.md
.metadata: any
- This is whatever comes out of the frontmatter parser in Step 3. (Note: that means if the outputmetadata
property isundefined
, this property will also beundefined
!)outputDirectory: string
- The absolute folder path of the output folder.site: Any
- This is whatever comes out of your merging function in Step 4. (If you don't provide a merging function, this property will not be set.)
The error-first callback should be called with an error as the first property, if there is one, or an object with the following properties as the second property:
stream: Readable
- This is astream.Readable
object, which should pipe out the rendered content.string: String
- If your output is small enough, you can pass it back on thestring
property instead.filepath: String
- The output file to write, relative to the output directory, e.g. ifoutput
is./build
and you wantbuild/articles/how-to-drive/index.html
this would bearticles/how-to-drive/index.html
. This property is given to thefinalize
function (in the next step) so even if you use Writable Streams you should return this.
If no object is provided to the callback function (or no stream
or filepath
is provided) the file will be ignored and not written.
Note: Ragu does not have an opinion about renderers! You will need to provide your own.
Here's an example:
// ragu.config.js
import renderHtml from './your-own-renderer.js'
export default {
// ... other stuff, then ...
render: ({ content, filepath, metadata, site }, callback) => {
const html = renderHtml(content, metadata, site)
callback(
false, // error-first: there is no error
{
string: html,
filepath: filepath.replace(/\.md$/, '/index.html'),
},
)
},
}
Here is an example of using streams:
// ragu.config.js
import { dirname, join } from 'node:path'
import renderHtmlStream from './your-own-streaming-renderer.js'
export default {
// ... other stuff, then ...
render: ({ outputDirectory, filepath, content, metadata, site }, callback) => {
const htmlStream = renderHtmlStream(content, metadata, site)
const relativeFilepath = filepath.replace(/\.md$/, '/index.html')
const absoluteFilepath = join(outputDirectory, relativeFilepath)
mkdir(dirname(absoluteFilepath), { recursive: true }, error => {
if (error) return callback(error)
const fileStream = createWriteStream(absoluteFilepath, { encoding: 'utf8' })
callback(false, { stream: fileStream, filepath: relativeFilepath })
htmlStream.pipe(fileStream)
})
},
After all files are written, an optional post-render function will be called if present.
// ragu.config.js
export default {
// ... other stuff, then ...
finalize: async ({ files, site }) => {
// copy other files, generate reports, etc.
},
}
The function is optionally asynchronous, and is called with an object containing the following entries:
files: Object
- This is a map where the key is the original filepath relative to theinput
folder, and the value is an object containing these entries:output: string
- The output filepath, relative to the configuredoutput
folder.metadata: any
- The output of the frontmatter parser for this file, from Step 3.
site: any
- The output of the metadata merge, from Step 4.
Published and released under the Very Open License.
If you need a commercial license, contact me here.