Proposal for metalsmith.get/set() helpers

Author: webketjeCreated Dec 21, 2022Updated Oct 1, 2023

Context

It is a frequent requirement for plugins to access and manipulate properties on metalsmith metadata or files. Retrieving and setting these properties is not always worry-free. For example, if a plugin wishes to allow users to specify plugin options to be read from a YAML file inside metalsmith.source(), let's say @metalsmith/layouts:

yaml
# src/layouts.config.yaml
---
directory: 'src/layouts'
pattern: '**/*.html'
engineOptions:
  gfm: true
---

it needs to check if the file is found, and do null-safe gets to nested properties

javascript
const confFile = files['layouts.config.yaml']
if (confFile) {
  if (confFile.engineOptions && confFile.engineOptions.gfm) { /* code */  }
}

This could be shortened to:

javascript
if (metalsmith.get('layouts.config.yaml', 'engineOptions.gfm')) { /* code */  }

Another need (or inflexibility) is that a lot of plugins "dictate" the key path in the file metadata they read config options from. For example using @metalsmith/layouts you must specify the key layout: in metadata. This will be ok for some (if not most) users, but other users may prefer to introduce different namespaces in front-matter (eg. translations, config, seo, content). So if the user would prefer reading the layout value from config.layout they should have a way to do that, without putting the onus of implementing the mechanism on the plugin developer. Building on the @metalsmith/layouts example, this would allow

javascript
Object.keys(files).forEach(path) => {
  metalsmith.set(metalsmith.get(path), 'contents', compiled)
})

// or better

const { get, set } = metalsmith
Object.keys(files).forEach(path => {
  set(get(path), 'contents', compiled)
})
javascript
const { get, set, metadata } = metalsmith
const blog = get('blog/index.md')
const blogTitle = get(blog, 'title')
set(metadata(), 'blog.title', blogTitle)

// copy a global metadata property to a file
const blog = get('blog/index.md')
set(blog, 'siteurl', metadata().siteurl)

// copy a file property to global metadata
const blogTitle = get(blog, 'title')
set(metadata(), 'title', blogTitle)

// add a new or overwrite an existing file
set(files, 'blog/new.md', { contents: Buffer.from('new or overwritten') })

set(blog, 'contents', Buffer.from('overwritten'))

Spec

  • The get/set methods have a base signature of get(context, keypath) and set(context, keypath, value)
  • The get/set methods should be instance-bound so they can be destructured in plugin bodies: const { get, set } = metalsmith.
  • The methods would default the context to files, so that it can be omitted and get('blog/index.md') and set('blog/index.md', { contents: Buffer.from('overwritten') }) operate on files.
  • When the context is equal to files, the methods could accept globs and work as shortcuts for resp. (get) metalsmith.match(glob).map(path => files[path]), always returning an array, and (set) metalsmith.match(glob).forEach(path => { files[path][keypath] = value })

Pros

  • allows getting a file matching a path with forward slash notation cross-platform metalsmith.get(files, 'nested/index.md').
  • allows plugin devs to provide more flexibility in specifying options `getOptions: { path: 'config/options.}

Signature

javascript
/**
 * Get a file
 * @param {string} path
 * @returns {Metalsmith.File} file at path or undefined
 **/
metalsmith.get(path) 

/**
 * Get a property value from context (file metadata, global metadata, or any object)
 * @param {string} context
 * @param {string} keypath
 * @returns {*} Value of the property at context[keypath] or undefined
 * @example
 * metalsmith.get(metalsmith.metadata(), 'seo.title')
 * metalsmith.get(files, 'index.md')
 * metalsmith.get(files['index.md'], 'title')
 * metalsmith.get(files['index.md'].collections, 0)
 **/
metalsmith.get(context, keypath)

/**
 * Set a file
 * @param {string} filepath
 * @param {string} file
 * @param {*} value
 * @returns {Metalsmith}
 * @throws EPERM when keypath is non-configurable
 **/

/**
 * Set a value to files[filepath][keypath]
 * @param {string} filepath
 * @param {string} keypath
 * @param {*} value
 * @returns {Metalsmith}
 * @throws ENOENT when filepath does not exist
 * @throws EPERM when keypath or filepath is non-configurable
 **/
metalsmith.set(filepath, keypath, value) 

/**
 * Set a property value to global metadata
 * @param {string} keypath
 * @param {*} value
 * @returns {Metalsmith}
 * @throws EPERM when keypath or filepath is non-configurable
 **/
metalsmith.set(metadata, keypath, value)