Repository: getindiekit/indiekit
Pull request #685 (2024-01-20 23:04:51)

Update Jekyll and Hugo presets to support all configured post types
Following on from #680, which removed the limitation around which properties could be output in a template, this PR updates the Jekyll and Hugo presets so that they support writing posts and files to a content store for any configured post type. It also reorganises plugins so split out `getPostType` and `getPostTemplate` functions into separate files and tests.


Patch:
diff --git a/package-lock.json b/package-lock.json
index 005e79ee..de1452a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10194,6 +10194,14 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/irregular-plurals": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz",
+      "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -14508,6 +14516,20 @@
         "node": ">=6"
       }
     },
+    "node_modules/plur": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz",
+      "integrity": "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==",
+      "dependencies": {
+        "irregular-plurals": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/pluralize": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -18351,6 +18373,7 @@
       "dependencies": {
         "@iarna/toml": "^2.2.5",
         "camelcase-keys": "^9.0.0",
+        "plur": "^5.1.0",
         "yaml": "^2.0.0"
       },
       "engines": {
@@ -18422,6 +18445,7 @@
       "version": "1.0.0-beta.6",
       "license": "MIT",
       "dependencies": {
+        "plur": "^5.1.0",
         "snakecase-keys": "^6.0.0",
         "yaml": "^2.0.0"
       },
diff --git a/packages/preset-hugo/index.js b/packages/preset-hugo/index.js
index 45ce435e..438d9b80 100644
--- a/packages/preset-hugo/index.js
+++ b/packages/preset-hugo/index.js
@@ -1,6 +1,5 @@
-import camelcaseKeys from "camelcase-keys";
-import TOML from "@iarna/toml";
-import YAML from "yaml";
+import { getPostTemplate } from "./lib/post-template.js";
+import { getPostTypes } from "./lib/post-types.js";
 
 const defaults = {
   frontMatterFormat: "yaml",
@@ -45,224 +44,14 @@ export default class HugoPreset {
     ];
   }
 
-  /**
-   * Get content
-   * @access private
-   * @param {object} properties - JF2 properties
-   * @returns {string} Content
-   */
-  #content(properties) {
-    if (properties.content) {
-      const content =
-        properties.content.text ||
-        properties.content.html ||
-        properties.content;
-      return `\n${content}\n`;
-    } else {
-      return "";
-    }
-  }
-
-  /**
-   * Get front matter
-   * @access private
-   * @param {object} properties - JF2 properties
-   * @returns {string} Front matter in chosen format
-   */
-  #frontMatter(properties) {
-    let delimiters;
-    let frontMatter;
-
-    /*
-     * Go templates don’t accept hyphens in property names
-     * and Hugo camelCases its predefined front matter keys
-     * @see {link: https://gohugo.io/content-management/front-matter/}
-     */
-    properties = camelcaseKeys(properties, { deep: true });
-
-    /*
-     * Replace Microformat properties with Hugo equivalents
-     * @see {link: https://gohugo.io/content-management/front-matter/}
-     */
-    properties = {
-      date: properties.published,
-      publishDate: properties.published,
-      ...(properties.postStatus === "draft" && { draft: true }),
-      ...(properties.updated && { lastmod: properties.updated }),
-      ...(properties.deleted && { expiryDate: properties.deleted }),
-      ...(properties.name && { title: properties.name }),
-      ...(properties.photo && {
-        images: properties.photo.map((image) => image.url),
-      }),
-      ...properties,
-    };
-
-    delete properties.content; // Shown below front matter
-    delete properties.deleted; // Use `expiryDate`
-    delete properties.name; // Use `title`
-    delete properties.postStatus; // Use `draft`
-    delete properties.published; // Use `date`
-    delete properties.type; // Not required
-    delete properties.updated; // Use `lastmod`
-    delete properties.url; // Not required
-
-    switch (this.options.frontMatterFormat) {
-      case "json": {
-        delimiters = ["", "\n"];
-        frontMatter = JSON.stringify(properties, undefined, 2);
-        break;
-      }
-
-      case "toml": {
-        delimiters = ["+++\n", "+++\n"];
-        frontMatter = TOML.stringify(properties);
-        break;
-      }
-
-      default: {
-        delimiters = ["---\n", "---\n"];
-        frontMatter = YAML.stringify(properties, { lineWidth: 0 });
-        break;
-      }
-    }
-
-    return `${delimiters[0]}${frontMatter}${delimiters[1]}`;
-  }
-
-  /**
-   * Post types
-   * @returns {object} Post types configuration
-   */
-  get postTypes() {
-    return [
-      {
-        type: "article",
-        name: "Article",
-        post: {
-          path: "content/articles/{slug}.md",
-          url: "articles/{slug}",
-        },
-        media: {
-          path: "static/articles/{filename}",
-          url: "articles/{filename}",
-        },
-      },
-      {
-        type: "note",
-        name: "Note",
-        post: {
-          path: "content/notes/{slug}.md",
-          url: "notes/{slug}",
-        },
-      },
-      {
-        type: "photo",
-        name: "Photo",
-        post: {
-          path: "content/photos/{slug}.md",
-          url: "photos/{slug}",
-        },
-        media: {
-          path: "static/photos/{filename}",
-          url: "photos/{filename}",
-        },
-      },
-      {
-        type: "video",
-        name: "Video",
-        post: {
-          path: "content/videos/{slug}.md",
-          url: "videos/{slug}",
-        },
-        media: {
-          path: "static/videos/{filename}",
-          url: "videos/{filename}",
-        },
-      },
-      {
-        type: "audio",
-        name: "Audio",
-        post: {
-          path: "content/audio/{slug}.md",
-          url: "audio/{slug}",
-        },
-        media: {
-          path: "static/audio/{filename}",
-          url: "audio/{filename}",
-        },
-      },
-      {
-        type: "bookmark",
-        name: "Bookmark",
-        post: {
-          path: "content/bookmarks/{slug}.md",
-          url: "bookmarks/{slug}",
-        },
-      },
-      {
-        type: "checkin",
-        name: "Checkin",
-        post: {
-          path: "content/checkins/{slug}.md",
-          url: "checkins/{slug}",
-        },
-      },
-      {
-        type: "event",
-        name: "Event",
-        post: {
-          path: "content/events/{slug}.md",
-          url: "events/{slug}",
-        },
-      },
-      {
-        type: "rsvp",
-        name: "RSVP",
-        post: {
-          path: "content/replies/{slug}.md",
-          url: "replies/{slug}",
-        },
-      },
-      {
-        type: "reply",
-        name: "Reply",
-        post: {
-          path: "content/replies/{slug}.md",
-          url: "replies/{slug}",
-        },
-      },
-      {
-        type: "repost",
-        name: "Repost",
-        post: {
-          path: "content/reposts/{slug}.md",
-          url: "reposts/{slug}",
-        },
-      },
-      {
-        type: "like",
-        name: "Like",
-        post: {
-          path: "content/likes/{slug}.md",
-          url: "likes/{slug}",
-        },
-      },
-    ];
-  }
-
-  /**
-   * Post template
-   * @param {object} properties - JF2 properties
-   * @returns {string} Rendered template
-   */
   postTemplate(properties) {
-    const content = this.#content(properties);
-    const frontMatter = this.#frontMatter(properties);
-
-    return frontMatter + content;
+    return getPostTemplate(properties, this.options.frontMatterFormat);
   }
 
   init(Indiekit) {
+    const { publication } = Indiekit.config;
+    this.postTypes = getPostTypes(publication.postTypes);
+
     Indiekit.addPreset(this);
   }
 }
diff --git a/packages/preset-hugo/lib/post-template.js b/packages/preset-hugo/lib/post-template.js
new file mode 100644
index 00000000..353c3212
--- /dev/null
+++ b/packages/preset-hugo/lib/post-template.js
@@ -0,0 +1,99 @@
+import camelcaseKeys from "camelcase-keys";
+import TOML from "@iarna/toml";
+import YAML from "yaml";
+
+/**
+ * Get content
+ * @access private
+ * @param {object} properties - JF2 properties
+ * @returns {string} Content
+ */
+const getContent = (properties) => {
+  if (properties.content) {
+    const content =
+      properties.content.text || properties.content.html || properties.content;
+    return `\n${content}\n`;
+  } else {
+    return "";
+  }
+};
+
+/**
+ * Get front matter
+ * @access private
+ * @param {object} properties - JF2 properties
+ * @param {string} frontMatterFormat - Front matter format
+ * @returns {string} Front matter in chosen format
+ */
+const getFrontMatter = (properties, frontMatterFormat) => {
+  let delimiters;
+  let frontMatter;
+
+  /*
+   * Go templates don’t accept hyphens in property names
+   * and Hugo camelCases its predefined front matter keys
+   * @see {link: https://gohugo.io/content-management/front-matter/}
+   */
+  properties = camelcaseKeys(properties, { deep: true });
+
+  /*
+   * Replace Microformat properties with Hugo equivalents
+   * @see {link: https://gohugo.io/content-management/front-matter/}
+   */
+  properties = {
+    date: properties.published,
+    publishDate: properties.published,
+    ...(properties.postStatus === "draft" && { draft: true }),
+    ...(properties.updated && { lastmod: properties.updated }),
+    ...(properties.deleted && { expiryDate: properties.deleted }),
+    ...(properties.name && { title: properties.name }),
+    ...(properties.photo && {
+      images: properties.photo.map((image) => image.url),
+    }),
+    ...properties,
+  };
+
+  delete properties.content; // Shown below front matter
+  delete properties.deleted; // Use `expiryDate`
+  delete properties.name; // Use `title`
+  delete properties.postStatus; // Use `draft`
+  delete properties.published; // Use `date`
+  delete properties.type; // Not required
+  delete properties.updated; // Use `lastmod`
+  delete properties.url; // Not required
+
+  switch (frontMatterFormat) {
+    case "json": {
+      delimiters = ["", "\n"];
+      frontMatter = JSON.stringify(properties, undefined, 2);
+      break;
+    }
+
+    case "toml": {
+      delimiters = ["+++\n", "+++\n"];
+      frontMatter = TOML.stringify(properties);
+      break;
+    }
+
+    default: {
+      delimiters = ["---\n", "---\n"];
+      frontMatter = YAML.stringify(properties, { lineWidth: 0 });
+      break;
+    }
+  }
+
+  return `${delimiters[0]}${frontMatter}${delimiters[1]}`;
+};
+
+/**
+ * Get post template
+ * @param {object} properties - JF2 properties
+ * @param {string} [frontMatterFormat] - Front matter format
+ * @returns {string} Rendered template
+ */
+export const getPostTemplate = (properties, frontMatterFormat = "yaml") => {
+  const content = getContent(properties);
+  const frontMatter = getFrontMatter(properties, frontMatterFormat);
+
+  return frontMatter + content;
+};
diff --git a/packages/preset-hugo/lib/post-types.js b/packages/preset-hugo/lib/post-types.js
new file mode 100644
index 00000000..e6476fb1
--- /dev/null
+++ b/packages/preset-hugo/lib/post-types.js
@@ -0,0 +1,34 @@
+import plur from "plur";
+
+/**
+ * Get paths and URLs for configured post types
+ * @param {object} postTypes - Post type configuration
+ * @returns {object} Updated post type configuration
+ */
+export const getPostTypes = (postTypes) => {
+  const types = [];
+
+  for (const postType of postTypes) {
+    const { type } = postType;
+    const section = plur(type);
+
+    /**
+     * Follow Hugo content management guidelines
+     * @see {@link https://gohugo.io/content-management/organization/}
+     * @see {@link https://gohugo.io/content-management/static-files/}
+     */
+    types.push({
+      type,
+      post: {
+        path: `content/${section}/{slug}.md`,
+        url: `${section}/{slug}`,
+      },
+      media: {
+        path: `static/${section}/{filename}`,
+        url: `${section}/{filename}`,
+      },
+    });
+  }
+
+  return types;
+};
diff --git a/packages/preset-hugo/package.json b/packages/preset-hugo/package.json
index ae4c88ff..975bb7af 100644
--- a/packages/preset-hugo/package.json
+++ b/packages/preset-hugo/package.json
@@ -22,6 +22,7 @@
   "main": "index.js",
   "files": [
     "assets",
+    "lib",
     "index.js"
   ],
   "bugs": {
@@ -35,6 +36,7 @@
   "dependencies": {
     "@iarna/toml": "^2.2.5",
     "camelcase-keys": "^9.0.0",
+    "plur": "^5.1.0",
     "yaml": "^2.0.0"
   },
   "publishConfig": {
diff --git a/packages/preset-jekyll/index.js b/packages/preset-jekyll/index.js
index 849d736e..3991f1d7 100644
--- a/packages/preset-jekyll/index.js
+++ b/packages/preset-jekyll/index.js
@@ -1,5 +1,5 @@
-import snakecaseKeys from "snakecase-keys";
-import YAML from "yaml";
+import { getPostTemplate } from "./lib/post-template.js";
+import { getPostTypes } from "./lib/post-types.js";
 
 export default class JekyllPreset {
   constructor() {
@@ -14,199 +14,14 @@ export default class JekyllPreset {
     };
   }
 
-  /**
-   * Get content
-   * @access private
-   * @param {object} properties - JF2 properties
-   * @returns {string} Content
-   */
-  #content(properties) {
-    if (properties.content) {
-      const content =
-        properties.content.text ||
-        properties.content.html ||
-        properties.content;
-      return `\n${content}\n`;
-    } else {
-      return "";
-    }
-  }
-
-  /**
-   * Get front matter
-   * @access private
-   * @param {object} properties - JF2 properties
-   * @returns {string} Front matter
-   */
-  #frontMatter(properties) {
-    /*
-     * Jekyll uses snake_case for YAML property keys, i.e. `excerpt_separator`
-     * @see {link: https://jekyllrb.com/docs/posts/#post-excerpts}
-     */
-    properties = snakecaseKeys(properties, { deep: true });
-
-    /*
-     * Replace Microformat properties with Jekyll equivalents
-     * @see {@link https://jekyllrb.com/docs/front-matter/#predefined-variables-for-posts}
-     */
-    properties = {
-      date: properties.published,
-      ...(properties.name && { title: properties.name }),
-      ...(properties.summary && { excerpt: properties.summary }),
-      ...properties,
-    };
-
-    /*
-     * Draft posts
-     * @see {@link https://jekyllrb.com/docs/front-matter/#predefined-global-variables}
-     */
-    if (properties.post_status === "draft") {
-      properties.published = false;
-    } else {
-      delete properties.published;
-    }
-
-    delete properties.content; // Shown below front matter
-    delete properties.name; // Use `title`
-    delete properties.post_status; // Use `published`
-    delete properties.summary; // Use `excerpt`
-    delete properties.type; // Not required
-    delete properties.url; // Not required
-
-    const frontMatter = YAML.stringify(properties, { lineWidth: 0 });
-    return `---\n${frontMatter}---\n`;
-  }
-
-  /**
-   * Post types
-   * @returns {object} Post types configuration
-   */
-  get postTypes() {
-    return [
-      {
-        type: "article",
-        name: "Article",
-        post: {
-          path: "_posts/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "{yyyy}/{MM}/{dd}/{slug}",
-        },
-        media: {
-          path: "media/{yyyy}/{MM}/{dd}/{filename}",
-        },
-      },
-      {
-        type: "note",
-        name: "Note",
-        post: {
-          path: "_notes/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "notes/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "photo",
-        name: "Photo",
-        post: {
-          path: "_photos/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "photos/{yyyy}/{MM}/{dd}/{slug}",
-        },
-        media: {
-          path: "media/photos/{yyyy}/{MM}/{dd}/{filename}",
-        },
-      },
-      {
-        type: "video",
-        name: "Video",
-        post: {
-          path: "_videos/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "videos/{yyyy}/{MM}/{dd}/{slug}",
-        },
-        media: {
-          path: "media/videos/{yyyy}/{MM}/{dd}/{filename}",
-        },
-      },
-      {
-        type: "audio",
-        name: "Audio",
-        post: {
-          path: "_audio/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "audio/{yyyy}/{MM}/{dd}/{slug}",
-        },
-        media: {
-          path: "media/audio/{yyyy}/{MM}/{dd}/{filename}",
-        },
-      },
-      {
-        type: "bookmark",
-        name: "Bookmark",
-        post: {
-          path: "_bookmarks/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "bookmarks/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "checkin",
-        name: "Checkin",
-        post: {
-          path: "_checkins/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "checkins/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "event",
-        name: "Event",
-        post: {
-          path: "_events/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "events/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "rsvp",
-        name: "RSVP",
-        post: {
-          path: "_replies/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "replies/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "reply",
-        name: "Reply",
-        post: {
-          path: "_replies/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "replies/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "repost",
-        name: "Repost",
-        post: {
-          path: "_reposts/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "reposts/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-      {
-        type: "like",
-        name: "Like",
-        post: {
-          path: "_likes/{yyyy}-{MM}-{dd}-{slug}.md",
-          url: "likes/{yyyy}/{MM}/{dd}/{slug}",
-        },
-      },
-    ];
-  }
-
-  /**
-   * Post template
-   * @param {object} properties - JF2 properties
-   * @returns {string} Rendered template
-   */
   postTemplate(properties) {
-    const content = this.#content(properties);
-    const frontMatter = this.#frontMatter(properties);
-
-    return frontMatter + content;
+    return getPostTemplate(properties);
   }
 
   init(Indiekit) {
+    const { publication } = Indiekit.config;
+    this.postTypes = getPostTypes(publication.postTypes);
+
     Indiekit.addPreset(this);
   }
 }
diff --git a/packages/preset-jekyll/lib/post-template.js b/packages/preset-jekyll/lib/post-template.js
new file mode 100644
index 00000000..45a32e41
--- /dev/null
+++ b/packages/preset-jekyll/lib/post-template.js
@@ -0,0 +1,75 @@
+import snakecaseKeys from "snakecase-keys";
+import YAML from "yaml";
+
+/**
+ * Get content
+ * @access private
+ * @param {object} properties - JF2 properties
+ * @returns {string} Content
+ */
+const getContent = (properties) => {
+  if (properties.content) {
+    const content =
+      properties.content.text || properties.content.html || properties.content;
+    return `\n${content}\n`;
+  } else {
+    return "";
+  }
+};
+
+/**
+ * Get front matter
+ * @access private
+ * @param {object} properties - JF2 properties
+ * @returns {string} Front matter in chosen format
+ */
+const getFrontMatter = (properties) => {
+  /*
+   * Jekyll uses snake_case for YAML property keys, i.e. `excerpt_separator`
+   * @see {link: https://jekyllrb.com/docs/posts/#post-excerpts}
+   */
+  properties = snakecaseKeys(properties, { deep: true });
+
+  /*
+   * Replace Microformat properties with Jekyll equivalents
+   * @see {@link https://jekyllrb.com/docs/front-matter/ #predefined-variables-for-posts}
+   */
+  properties = {
+    date: properties.published,
+    ...(properties.name && { title: properties.name }),
+    ...(properties.summary && { excerpt: properties.summary }),
+    ...properties,
+  };
+
+  /*
+   * Draft posts
+   * @see {@link https://jekyllrb.com/docs/front-matter/#predefined-global-variables}
+   */
+  if (properties.post_status === "draft") {
+    properties.published = false;
+  } else {
+    delete properties.published;
+  }
+
+  delete properties.content; // Shown below front matter
+  delete properties.name; // Use `title`
+  delete properties.post_status; // Use `published`
+  delete properties.summary; // Use `excerpt`
+  delete properties.type; // Not required
+  delete properties.url; // Not required
+
+  const frontMatter = YAML.stringify(properties, { lineWidth: 0 });
+  return `---\n${frontMatter}---\n`;
+};
+
+/**
+ * Get post template
+ * @param {object} properties - JF2 properties
+ * @returns {string} Rendered template
+ */
+export const getPostTemplate = (properties) => {
+  const content = getContent(properties);
+  const frontMatter = getFrontMatter(properties);
+
+  return frontMatter + content;
+};
diff --git a/packages/preset-jekyll/lib/post-types.js b/packages/preset-jekyll/lib/post-types.js
new file mode 100644
index 00000000..5690ad4c
--- /dev/null
+++ b/packages/preset-jekyll/lib/post-types.js
@@ -0,0 +1,49 @@
+import plur from "plur";
+
+/**
+ * Get paths and URLs for configured post types
+ * @param {object} postTypes - Post type configuration
+ * @returns {object} Updated post type configuration
+ */
+export const getPostTypes = (postTypes) => {
+  const types = [];
+
+  for (const postType of postTypes) {
+    const { type } = postType;
+    const collection = plur(type);
+
+    if (type === "article") {
+      /**
+       * Posts use `_posts` folder
+       * @see {@link https://jekyllrb.com/docs/posts/}
+       */
+      types.push({
+        type,
+        post: {
+          path: "_posts/{yyyy}-{MM}-{dd}-{slug}.md",
+          url: "{yyyy}/{MM}/{dd}/{slug}",
+        },
+        media: {
+          path: "media/{yyyy}/{MM}/{dd}/{filename}",
+        },
+      });
+    } else {
+      /**
+       * Other post types use collection folders
+       * @see {@link https://jekyllrb.com/docs/collections/}
+       */
+      types.push({
+        type,
+        post: {
+          path: `_${collection}/{yyyy}-{MM}-{dd}-{slug}.md`,
+          url: `${collection}/{yyyy}/{MM}/{dd}/{slug}`,
+        },
+        media: {
+          path: `media/${collection}/{yyyy}/{MM}/{dd}/{filename}`,
+        },
+      });
+    }
+  }
+
+  return types;
+};
diff --git a/packages/preset-jekyll/package.json b/packages/preset-jekyll/package.json
index 58dc8d06..a6637ce6 100644
--- a/packages/preset-jekyll/package.json
+++ b/packages/preset-jekyll/package.json
@@ -25,6 +25,7 @@
   "main": "index.js",
   "files": [
     "assets",
+    "lib",
     "index.js"
   ],
   "bugs": {
@@ -37,6 +38,7 @@
   },
   "dependencies": {
     "snakecase-keys": "^6.0.0",
+    "plur": "^5.1.0",
     "yaml": "^2.0.0"
   },
   "publishConfig": {


Test patch:
diff --git a/packages/indiekit/test/unit/post-types.js b/packages/indiekit/test/unit/post-types.js
index 79cc674d..32383ded 100644
--- a/packages/indiekit/test/unit/post-types.js
+++ b/packages/indiekit/test/unit/post-types.js
@@ -14,13 +14,16 @@ describe("indiekit/lib/post-types", () => {
     const { publication } = await indiekit.bootstrap();
     const result = getPostTypes(publication);
 
-    assert.deepEqual(result[1], {
+    assert.deepEqual(result[0], {
       name: "Custom note post type",
       type: "note",
       post: {
         path: "src/content/notes/{slug}.md",
         url: "notes/{slug}/",
       },
+      media: {
+        path: "media/notes/{yyyy}/{MM}/{dd}/{filename}",
+      },
     });
   });
 
diff --git a/packages/preset-hugo/test/index.js b/packages/preset-hugo/test/index.js
index 4fdc6ef3..c7775241 100644
--- a/packages/preset-hugo/test/index.js
+++ b/packages/preset-hugo/test/index.js
@@ -1,13 +1,28 @@
 import { strict as assert } from "node:assert";
 import { describe, it } from "node:test";
 import { Indiekit } from "@indiekit/indiekit";
-import { getFixture } from "@indiekit-test/fixtures";
 import HugoPreset from "../index.js";
 
-describe("preset-hugo", () => {
+describe("preset-hugo", async () => {
   const hugo = new HugoPreset();
+  const indiekit = await Indiekit.initialize({
+    config: {
+      publication: {
+        postTypes: [
+          {
+            type: "puppy",
+            name: "Puppy posts",
+          },
+        ],
+      },
+    },
+  });
 
-  const properties = JSON.parse(getFixture("jf2/post-template-properties.jf2"));
+  hugo.init(indiekit);
+
+  it("Initiates plug-in", async () => {
+    assert.equal(indiekit.publication.preset.info.name, "Hugo");
+  });
 
   it("Gets plug-in info", () => {
     assert.equal(hugo.name, "Hugo preset");
@@ -21,227 +36,19 @@ describe("preset-hugo", () => {
     );
   });
 
-  it("Initiates plug-in", async () => {
-    const indiekit = await Indiekit.initialize({ config: {} });
-    hugo.init(indiekit);
-
-    assert.equal(indiekit.publication.preset.info.name, "Hugo");
-  });
-
   it("Gets publication post types", () => {
-    const result = hugo.postTypes;
-
-    assert.equal(result[0].type, "article");
-  });
-
-  it("Renders post template without content", () => {
-    const result = hugo.postTemplate({
-      published: "2020-02-02",
-      updated: "2022-12-11",
-      deleted: "2022-12-12",
-      name: "What I had for lunch",
+    assert.deepEqual(hugo.postTypes[0].post, {
+      path: "content/puppies/{slug}.md",
+      url: "puppies/{slug}",
     });
-
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-publishDate: 2020-02-02
-lastmod: 2022-12-11
-expiryDate: 2022-12-12
-title: What I had for lunch
----
-`,
-    );
-  });
-
-  it("Renders post template with basic draft content", () => {
-    const result = hugo.postTemplate({
-      published: "2020-02-02",
-      name: "What I had for lunch",
-      content:
-        "I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.",
-      "post-status": "draft",
-    });
-
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-publishDate: 2020-02-02
-draft: true
-title: What I had for lunch
----
-
-I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
-`,
-    );
   });
 
-  it("Renders post template with HTML content", () => {
+  it("Renders post template", () => {
     const result = hugo.postTemplate({
       published: "2020-02-02",
-      name: "What I had for lunch",
-      content: {
-        html: '<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>',
-      },
+      name: "Lunchtime",
     });
 
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-publishDate: 2020-02-02
-title: What I had for lunch
----
-
-<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>
-`,
-    );
-  });
-
-  it("Renders post template with JSON front matter", () => {
-    const hugo = new HugoPreset({ frontMatterFormat: "json" });
-    const result = hugo.postTemplate(properties);
-
-    assert.equal(
-      result,
-      `{
-  "date": "2020-02-02",
-  "publishDate": "2020-02-02",
-  "title": "What I had for lunch",
-  "images": [
-    "https://website.example/photo.jpg"
-  ],
-  "summary": "A very satisfactory meal.",
-  "category": [
-    "lunch",
-    "food"
-  ],
-  "audio": [
-    {
-      "url": "https://website.example/audio.mp3"
-    }
-  ],
-  "photo": [
-    {
-      "alt": "Alternative text",
-      "url": "https://website.example/photo.jpg"
-    }
-  ],
-  "video": [
-    {
-      "url": "https://website.example/video.mp4"
-    }
-  ],
-  "start": "2020-02-02",
-  "end": "2020-02-20",
-  "rsvp": "Yes",
-  "location": {
-    "type": "geo",
-    "latitude": "37.780080",
-    "longitude": "-122.420160",
-    "name": "37° 46′ 48.29″ N 122° 25′ 12.576″ W"
-  },
-  "bookmarkOf": "https://website.example",
-  "likeOf": "https://website.example",
-  "repostOf": "https://website.example",
-  "inReplyTo": "https://website.example",
-  "visibility": "private",
-  "syndication": "https://website.example/post/12345"
-}
-
-I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
-`,
-    );
-  });
-
-  it("Renders post template with TOML front matter", () => {
-    const hugo = new HugoPreset({ frontMatterFormat: "toml" });
-    const result = hugo.postTemplate(properties);
-
-    assert.equal(
-      result,
-      `+++
-date = "2020-02-02"
-publishDate = "2020-02-02"
-title = "What I had for lunch"
-images = [ "https://website.example/photo.jpg" ]
-summary = "A very satisfactory meal."
-category = [ "lunch", "food" ]
-start = "2020-02-02"
-end = "2020-02-20"
-rsvp = "Yes"
-bookmarkOf = "https://website.example"
-likeOf = "https://website.example"
-repostOf = "https://website.example"
-inReplyTo = "https://website.example"
-visibility = "private"
-syndication = "https://website.example/post/12345"
-
-[[audio]]
-url = "https://website.example/audio.mp3"
-
-[[photo]]
-alt = "Alternative text"
-url = "https://website.example/photo.jpg"
-
-[[video]]
-url = "https://website.example/video.mp4"
-
-[location]
-type = "geo"
-latitude = "37.780080"
-longitude = "-122.420160"
-name = "37° 46′ 48.29″ N 122° 25′ 12.576″ W"
-+++
-
-I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
-`,
-    );
-  });
-
-  it("Renders post template with YAML front matter", () => {
-    const hugo = new HugoPreset({ frontMatterFormat: "yaml" });
-    const result = hugo.postTemplate(properties);
-
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-publishDate: 2020-02-02
-title: What I had for lunch
-images:
-  - https://website.example/photo.jpg
-summary: A very satisfactory meal.
-category:
-  - lunch
-  - food
-audio:
-  - url: https://website.example/audio.mp3
-photo:
-  - alt: Alternative text
-    url: https://website.example/photo.jpg
-video:
-  - url: https://website.example/video.mp4
-start: 2020-02-02
-end: 2020-02-20
-rsvp: Yes
-location:
-  type: geo
-  latitude: "37.780080"
-  longitude: "-122.420160"
-  name: 37° 46′ 48.29″ N 122° 25′ 12.576″ W
-bookmarkOf: https://website.example
-likeOf: https://website.example
-repostOf: https://website.example
-inReplyTo: https://website.example
-visibility: private
-syndication: https://website.example/post/12345
----
-
-I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
-`,
-    );
+    assert.equal(result.includes("date: 2020-02-02"), true);
   });
 });
diff --git a/packages/preset-hugo/test/unit/post-template.js b/packages/preset-hugo/test/unit/post-template.js
new file mode 100644
index 00000000..9638090e
--- /dev/null
+++ b/packages/preset-hugo/test/unit/post-template.js
@@ -0,0 +1,216 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import { getFixture } from "@indiekit-test/fixtures";
+import { getPostTemplate } from "../../lib/post-template.js";
+
+describe("preset-jekyll/lib/post-template", async () => {
+  const properties = JSON.parse(getFixture("jf2/post-template-properties.jf2"));
+
+  it("Renders post template without content", () => {
+    const result = getPostTemplate({
+      published: "2020-02-02",
+      updated: "2022-12-11",
+      deleted: "2022-12-12",
+      name: "What I had for lunch",
+    });
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+publishDate: 2020-02-02
+lastmod: 2022-12-11
+expiryDate: 2022-12-12
+title: What I had for lunch
+---
+`,
+    );
+  });
+
+  it("Renders post template with basic draft content", () => {
+    const result = getPostTemplate({
+      published: "2020-02-02",
+      name: "What I had for lunch",
+      content:
+        "I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.",
+      "post-status": "draft",
+    });
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+publishDate: 2020-02-02
+draft: true
+title: What I had for lunch
+---
+
+I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
+`,
+    );
+  });
+
+  it("Renders post template with HTML content", () => {
+    const result = getPostTemplate({
+      published: "2020-02-02",
+      name: "What I had for lunch",
+      content: {
+        html: '<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>',
+      },
+    });
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+publishDate: 2020-02-02
+title: What I had for lunch
+---
+
+<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>
+`,
+    );
+  });
+
+  it("Renders post template with JSON front matter", () => {
+    const result = getPostTemplate(properties, "json");
+
+    assert.equal(
+      result,
+      `{
+  "date": "2020-02-02",
+  "publishDate": "2020-02-02",
+  "title": "What I had for lunch",
+  "images": [
+    "https://website.example/photo.jpg"
+  ],
+  "summary": "A very satisfactory meal.",
+  "category": [
+    "lunch",
+    "food"
+  ],
+  "audio": [
+    {
+      "url": "https://website.example/audio.mp3"
+    }
+  ],
+  "photo": [
+    {
+      "alt": "Alternative text",
+      "url": "https://website.example/photo.jpg"
+    }
+  ],
+  "video": [
+    {
+      "url": "https://website.example/video.mp4"
+    }
+  ],
+  "start": "2020-02-02",
+  "end": "2020-02-20",
+  "rsvp": "Yes",
+  "location": {
+    "type": "geo",
+    "latitude": "37.780080",
+    "longitude": "-122.420160",
+    "name": "37° 46′ 48.29″ N 122° 25′ 12.576″ W"
+  },
+  "bookmarkOf": "https://website.example",
+  "likeOf": "https://website.example",
+  "repostOf": "https://website.example",
+  "inReplyTo": "https://website.example",
+  "visibility": "private",
+  "syndication": "https://website.example/post/12345"
+}
+
+I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
+`,
+    );
+  });
+
+  it("Renders post template with TOML front matter", () => {
+    const result = getPostTemplate(properties, "toml");
+
+    assert.equal(
+      result,
+      `+++
+date = "2020-02-02"
+publishDate = "2020-02-02"
+title = "What I had for lunch"
+images = [ "https://website.example/photo.jpg" ]
+summary = "A very satisfactory meal."
+category = [ "lunch", "food" ]
+start = "2020-02-02"
+end = "2020-02-20"
+rsvp = "Yes"
+bookmarkOf = "https://website.example"
+likeOf = "https://website.example"
+repostOf = "https://website.example"
+inReplyTo = "https://website.example"
+visibility = "private"
+syndication = "https://website.example/post/12345"
+
+[[audio]]
+url = "https://website.example/audio.mp3"
+
+[[photo]]
+alt = "Alternative text"
+url = "https://website.example/photo.jpg"
+
+[[video]]
+url = "https://website.example/video.mp4"
+
+[location]
+type = "geo"
+latitude = "37.780080"
+longitude = "-122.420160"
+name = "37° 46′ 48.29″ N 122° 25′ 12.576″ W"
++++
+
+I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
+`,
+    );
+  });
+
+  it("Renders post template with YAML front matter", () => {
+    const result = getPostTemplate(properties, "yaml");
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+publishDate: 2020-02-02
+title: What I had for lunch
+images:
+  - https://website.example/photo.jpg
+summary: A very satisfactory meal.
+category:
+  - lunch
+  - food
+audio:
+  - url: https://website.example/audio.mp3
+photo:
+  - alt: Alternative text
+    url: https://website.example/photo.jpg
+video:
+  - url: https://website.example/video.mp4
+start: 2020-02-02
+end: 2020-02-20
+rsvp: Yes
+location:
+  type: geo
+  latitude: "37.780080"
+  longitude: "-122.420160"
+  name: 37° 46′ 48.29″ N 122° 25′ 12.576″ W
+bookmarkOf: https://website.example
+likeOf: https://website.example
+repostOf: https://website.example
+inReplyTo: https://website.example
+visibility: private
+syndication: https://website.example/post/12345
+---
+
+I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
+`,
+    );
+  });
+});
diff --git a/packages/preset-hugo/test/unit/post-types.js b/packages/preset-hugo/test/unit/post-types.js
new file mode 100644
index 00000000..dd4b4872
--- /dev/null
+++ b/packages/preset-hugo/test/unit/post-types.js
@@ -0,0 +1,58 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import { getPostTypes } from "../../lib/post-types.js";
+
+const postTypes = [
+  {
+    type: "article",
+    name: "Journal post",
+  },
+  {
+    type: "note",
+    name: "Micro post",
+  },
+  {
+    type: "puppy",
+    name: "Puppy post",
+  },
+];
+
+describe("preset-hugo/lib/post-types", () => {
+  it("Gets paths and URLs for configured post types", () => {
+    assert.deepEqual(getPostTypes(postTypes), [
+      {
+        type: "article",
+        post: {
+          path: "content/articles/{slug}.md",
+          url: "articles/{slug}",
+        },
+        media: {
+          path: "static/articles/{filename}",
+          url: "articles/{filename}",
+        },
+      },
+      {
+        type: "note",
+        post: {
+          path: "content/notes/{slug}.md",
+          url: "notes/{slug}",
+        },
+        media: {
+          path: "static/notes/{filename}",
+          url: "notes/{filename}",
+        },
+      },
+      {
+        type: "puppy",
+        post: {
+          path: "content/puppies/{slug}.md",
+          url: "puppies/{slug}",
+        },
+        media: {
+          path: "static/puppies/{filename}",
+          url: "puppies/{filename}",
+        },
+      },
+    ]);
+  });
+});
diff --git a/packages/preset-jekyll/test/index.js b/packages/preset-jekyll/test/index.js
index 9c5ae4da..e5dae444 100644
--- a/packages/preset-jekyll/test/index.js
+++ b/packages/preset-jekyll/test/index.js
@@ -1,133 +1,47 @@
 import { strict as assert } from "node:assert";
 import { describe, it } from "node:test";
 import { Indiekit } from "@indiekit/indiekit";
-import { getFixture } from "@indiekit-test/fixtures";
 import JekyllPreset from "../index.js";
 
-describe("preset-jekyll", () => {
+describe("preset-jekyll", async () => {
   const jekyll = new JekyllPreset();
-
-  const properties = JSON.parse(getFixture("jf2/post-template-properties.jf2"));
-
-  it("Gets plug-in info", () => {
-    assert.equal(jekyll.name, "Jekyll preset");
-    assert.equal(jekyll.info.name, "Jekyll");
+  const indiekit = await Indiekit.initialize({
+    config: {
+      publication: {
+        postTypes: [
+          {
+            type: "puppy",
+            name: "Puppy posts",
+          },
+        ],
+      },
+    },
   });
 
-  it("Initiates plug-in", async () => {
-    const indiekit = await Indiekit.initialize({ config: {} });
-    jekyll.init(indiekit);
+  jekyll.init(indiekit);
 
+  it("Initiates plug-in", async () => {
     assert.equal(indiekit.publication.preset.info.name, "Jekyll");
   });
 
-  it("Gets publication post types", () => {
-    const result = jekyll.postTypes;
-
-    assert.equal(result[0].type, "article");
-  });
-
-  it("Renders post template without content", () => {
-    const result = jekyll.postTemplate({
-      published: "2020-02-02",
-      updated: "2022-12-11",
-      deleted: "2022-12-12",
-      name: "Lunchtime",
-    });
-
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-title: Lunchtime
-updated: 2022-12-11
-deleted: 2022-12-12
----
-`,
-    );
+  it("Gets plug-in info", () => {
+    assert.equal(jekyll.name, "Jekyll preset");
+    assert.equal(jekyll.info.name, "Jekyll");
   });
 
-  it("Renders post template with basic draft content", () => {
-    const result = jekyll.postTemplate({
-      published: "2020-02-02",
-      name: "Lunchtime",
-      content:
-        "I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.",
-      post_status: "draft",
+  it("Gets publication post types", () => {
+    assert.deepEqual(jekyll.postTypes[0].post, {
+      path: "_puppies/{yyyy}-{MM}-{dd}-{slug}.md",
+      url: "puppies/{yyyy}/{MM}/{dd}/{slug}",
     });
-
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-title: Lunchtime
-published: false
----
-
-I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
-`,
-    );
   });
 
-  it("Renders post template with HTML content", () => {
+  it("Renders post template", () => {
     const result = jekyll.postTemplate({
       published: "2020-02-02",
       name: "Lunchtime",
-      content: {
-        html: '<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>',
-      },
     });
 
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-title: Lunchtime
----
-
-<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>
-`,
-    );
-  });
-
-  it("Renders post template", () => {
-    const jekyll = new JekyllPreset();
-    const result = jekyll.postTemplate(properties);
-
-    assert.equal(
-      result,
-      `---
-date: 2020-02-02
-title: What I had for lunch
-excerpt: A very satisfactory meal.
-category:
-  - lunch
-  - food
-audio:
-  - url: https://website.example/audio.mp3
-photo:
-  - alt: Alternative text
-    url: https://website.example/photo.jpg
-video:
-  - url: https://website.example/video.mp4
-start: 2020-02-02
-end: 2020-02-20
-rsvp: Yes
-location:
-  type: geo
-  latitude: "37.780080"
-  longitude: "-122.420160"
-  name: 37° 46′ 48.29″ N 122° 25′ 12.576″ W
-bookmark_of: https://website.example
-like_of: https://website.example
-repost_of: https://website.example
-in_reply_to: https://website.example
-visibility: private
-syndication: https://website.example/post/12345
----
-
-I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
-`,
-    );
+    assert.equal(result.includes("date: 2020-02-02"), true);
   });
 });
diff --git a/packages/preset-jekyll/test/unit/post-template.js b/packages/preset-jekyll/test/unit/post-template.js
new file mode 100644
index 00000000..a004b06c
--- /dev/null
+++ b/packages/preset-jekyll/test/unit/post-template.js
@@ -0,0 +1,111 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import { getFixture } from "@indiekit-test/fixtures";
+import { getPostTemplate } from "../../lib/post-template.js";
+
+describe("preset-jekyll/lib/post-template", async () => {
+  const properties = JSON.parse(getFixture("jf2/post-template-properties.jf2"));
+
+  it("Renders post template without content", () => {
+    const result = getPostTemplate({
+      published: "2020-02-02",
+      updated: "2022-12-11",
+      deleted: "2022-12-12",
+      name: "Lunchtime",
+    });
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+title: Lunchtime
+updated: 2022-12-11
+deleted: 2022-12-12
+---
+`,
+    );
+  });
+
+  it("Renders post template with basic draft content", () => {
+    const result = getPostTemplate({
+      published: "2020-02-02",
+      name: "Lunchtime",
+      content:
+        "I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.",
+      "post-status": "draft",
+    });
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+title: Lunchtime
+published: false
+---
+
+I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
+`,
+    );
+  });
+
+  it("Renders post template with HTML content", () => {
+    const result = getPostTemplate({
+      published: "2020-02-02",
+      name: "Lunchtime",
+      content: {
+        html: '<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>',
+      },
+    });
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+title: Lunchtime
+---
+
+<p>I ate a <a href="https://en.wikipedia.org/wiki/Cheese">cheese</a> sandwich, which was nice.</p>
+`,
+    );
+  });
+
+  it("Renders post template", () => {
+    const result = getPostTemplate(properties);
+
+    assert.equal(
+      result,
+      `---
+date: 2020-02-02
+title: What I had for lunch
+excerpt: A very satisfactory meal.
+category:
+  - lunch
+  - food
+audio:
+  - url: https://website.example/audio.mp3
+photo:
+  - alt: Alternative text
+    url: https://website.example/photo.jpg
+video:
+  - url: https://website.example/video.mp4
+start: 2020-02-02
+end: 2020-02-20
+rsvp: Yes
+location:
+  type: geo
+  latitude: "37.780080"
+  longitude: "-122.420160"
+  name: 37° 46′ 48.29″ N 122° 25′ 12.576″ W
+bookmark_of: https://website.example
+like_of: https://website.example
+repost_of: https://website.example
+in_reply_to: https://website.example
+visibility: private
+syndication: https://website.example/post/12345
+---
+
+I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.
+`,
+    );
+  });
+});
diff --git a/packages/preset-jekyll/test/unit/post-types.js b/packages/preset-jekyll/test/unit/post-types.js
new file mode 100644
index 00000000..0ec8634a
--- /dev/null
+++ b/packages/preset-jekyll/test/unit/post-types.js
@@ -0,0 +1,55 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import { getPostTypes } from "../../lib/post-types.js";
+
+const postTypes = [
+  {
+    type: "article",
+    name: "Journal post",
+  },
+  {
+    type: "note",
+    name: "Micro post",
+  },
+  {
+    type: "puppy",
+    name: "Puppy post",
+  },
+];
+
+describe("preset-jekyll/lib/post-types", () => {
+  it("Gets paths and URLs for configured post types", () => {
+    assert.deepEqual(getPostTypes(postTypes), [
+      {
+        type: "article",
+        post: {
+          path: "_posts/{yyyy}-{MM}-{dd}-{slug}.md",
+          url: "{yyyy}/{MM}/{dd}/{slug}",
+        },
+        media: {
+          path: "media/{yyyy}/{MM}/{dd}/{filename}",
+        },
+      },
+      {
+        type: "note",
+        post: {
+          path: "_notes/{yyyy}-{MM}-{dd}-{slug}.md",
+          url: "notes/{yyyy}/{MM}/{dd}/{slug}",
+        },
+        media: {
+          path: "media/notes/{yyyy}/{MM}/{dd}/{filename}",
+        },
+      },
+      {
+        type: "puppy",
+        post: {
+          path: "_puppies/{yyyy}-{MM}-{dd}-{slug}.md",
+          url: "puppies/{yyyy}/{MM}/{dd}/{slug}",
+        },
+        media: {
+          path: "media/puppies/{yyyy}/{MM}/{dd}/{filename}",
+        },
+      },
+    ]);
+  });
+});