Repository: getindiekit/indiekit (js)

Issue:
mp-slug removed from post template properties
### Description of the bug

I have an 11ty site that either uses the title or a slug frontmatter property to define the slug. Recently I started noticing some of my pages were being posted at /YYYY/MM/DD/undefined/ paths with the slug missing. I checked the properties passed and `mp-slug` was indeed no longer passed.

It seems like recently (beta.8??) the `mp-*` properties were removed. I couldn't fine the exact commit but I saw it was intentional. While the other micropub directives don't seem important, the slug is used in rendering. I guess jekyll pulls from the file name, but that's not true for all SSGs.

### Steps to reproduce

Console.log all the properties sent to a post template function and create a new post.

### Expected behaviour

`mp-slug` or `slug` is returned in the post template properties or document the change better in the release notes (assuming I didn't miss something obvious).

### Environment

https://github.com/aciccarello/ciccarello-indiekit/blob/main/lib/formatFile.js

### Additional context

I should be able to work around this with the `url` property.

https://github.com/aciccarello/ciccarello-indiekit/commit/8f96c9f2bc3c5dfe3c21da1d9382f3a6cf357cc0

Pull request description:
Decouple slug property from slug path tokens; add `random` path token
Fixes #717 

Previously, an `mp-slug` property would be created for a post, even if a user hadn’t chosen a slug (using slugified `name` if no `mp-slug`, else generating a random 5 character string if no `name`). This `mp-slug` value was then used for the `{slug}` path token.

In a recent beta however, `mp-*` values were not provided to the post template function. Even if a slug was defined, this value was no longer accessible to post templates. 

In what might be a subtle change, this PR makes it so that if a value for `mp-slug` is provided, a `slug` value is saved to post properties (and thus available to front matter and downstream static site generators).

However, if no `mp-slug` is provided, no `slug` value is saved to the post template. However, as path may contain a `{slug}` token, this will maintain the existing fallback mechanism, that of using a slugified version of the `name` property, else a random 5 character string.

## Media path tokens

There’s a bit of inconsistency between post and media tokens. This can be ironed out as so:

- A new `{random}` token takes the place of `{basename}`, and this token can now be used for posts and media paths
- The `{originalname}` token is renamed `{filename}`
- To maintain the previous behaviour, you can use `{random}.{ext}`

## Slug behaviour across versions

### <= Beta 7

Request            | Post template | Presets | `{slug}` token   |
------------------ | ------------- | ------- | ---------------- |
Includes `mp-slug` | ✔︎ (`mp-slug`) | ✘       | ✔︎ (mp-slug)      |
Includes `name`    | ✔︎ (`mp-slug`) | ✘       | ✔︎ (name)         |
None of the above  | ✔︎ (`mp-slug`) | ✘       | ✔︎ (random)       |

**TL;DR:** `mp-slug` property always present with fallback value generated if no `mp-slug` in original Micropub request. Presets ignored `mp-slug` property.

### Beta 8

Request            | Post template | Presets | `{slug}` token   |
------------------ | ------------- | ------- | ---------------- |
Includes `mp-slug` | ✘             | ✘       | ✔︎ (mp-slug)      |
Includes `name`    | ✘             | ✘       | ✔︎ (name)         |
None of the above  | ✘             | ✘       | ✔︎ (random)       |

**TL;DR:** `mp-slug` not available to post template, only used for `{slug}` path token (with original fallback mechanism). Presets ignored `mp-slug` property.

### Next

Request            | Post template | Presets    | `{slug}` token   |
------------------ | ------------- | ---------- | ---------------- |
Includes `mp-slug` | ✔︎ (`slug`)    | ✔︎ (`slug`) | ✔︎ (slug)         |
Includes `name`    | ✘             | ✘          | ✔︎ (name)         |
None of the above  | ✘             | ✘          | ✔︎ (random)       |

**TL;DR:** `mp-slug` used for `slug` property sent to post template and presets, with a value always generated for `{slug}` token using original fallback mechanism.

Patch:
diff --git a/docs/configuration/tokens.md b/docs/configuration/tokens.md
index 58eab0e1..d71388c4 100644
--- a/docs/configuration/tokens.md
+++ b/docs/configuration/tokens.md
@@ -51,6 +51,7 @@ Tokens are available for a number of file properties, with many allowing you to
 | `ss` | Second (zero-padded), for example `01` |
 | `t` | UNIX epoch seconds, for example `512969520` |
 | `T` | UNIX epoch milliseconds, for example `51296952000` |
+| `random` | A random 5-character string, for example `w9gwi` |
 | `uuid` | A [random UUID][uuid] |
 | `n` | Incremental count of posts (for type) in the same day, for example `1`. This token requires a [database to be configured](https://getindiekit.com/configuration/#application-mongodburl-url). |
 
@@ -60,7 +61,7 @@ The following tokens are only available for post files:
 
 | Token | Description |
 | :---- | :---------- |
-| `slug` | Slug provided in `mp-slug` property, slugified `name` or a 5 character string, for example `ycf9o` |
+| `slug` | Slug provided in `mp-slug` property, else slugified `name` property, else a 5 character string, for example `ycf9o` |
 
 ### Media file tokens
 
@@ -68,9 +69,7 @@ The following tokens are only available for media files:
 
 | Token | Description |
 | :---- | :---------- |
-| `basename` | 5 character alpha-numeric string, for example `w9gwi` |
 | `ext` | File extension of uploaded file, for example `jpg` |
-| `filename` | `basename` plus `ext`, for example `w9gwi.jpg` |
-| `originalname` | Original name of uploaded file, for example `flower.jpg` |
+| `filename` | Original name of uploaded file, for example `flower.jpg` |
 
 [uuid]: https://www.rfc-editor.org/rfc/rfc4122.html#section-4.4
diff --git a/helpers/fixtures/jf2/article-content-provided.jf2 b/helpers/fixtures/jf2/article-content-provided.jf2
index 3c78b3ff..f76d05ad 100644
--- a/helpers/fixtures/jf2/article-content-provided.jf2
+++ b/helpers/fixtures/jf2/article-content-provided.jf2
@@ -1,5 +1,6 @@
 {
   "type": "entry",
   "name": "What I had for lunch",
-  "content": "> I <del>ate</del><ins>had</ins> a <cite>[cheese](https://en.wikipedia.org/wiki/Cheese)</cite> sandwich from https://cafe.example, which was > 10.\n\n-- Me, then."
+  "content": "> I <del>ate</del><ins>had</ins> a <cite>[cheese](https://en.wikipedia.org/wiki/Cheese)</cite> sandwich from https://cafe.example, which was > 10.\n\n-- Me, then.",
+  "mp-slug": "lunch"
 }
diff --git a/helpers/fixtures/jf2/article-slug-provided-empty.jf2 b/helpers/fixtures/jf2/article-slug-provided-empty.jf2
deleted file mode 100644
index 092afe4a..00000000
--- a/helpers/fixtures/jf2/article-slug-provided-empty.jf2
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-  "type": "entry",
-  "name": "What I had for lunch",
-  "content": "I ate a *cheese* sandwich, which was nice.",
-  "mp-slug": ""
-}
diff --git a/helpers/fixtures/jf2/note-slug-missing-no-name.jf2 b/helpers/fixtures/jf2/note-slug-missing-no-name.jf2
deleted file mode 100644
index c6375e3a..00000000
--- a/helpers/fixtures/jf2/note-slug-missing-no-name.jf2
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "type": "entry",
-  "content": "I ate a cheese sandwich, which was nice."
-}
diff --git a/helpers/fixtures/jf2/note-slug-provided-unslugified.jf2 b/helpers/fixtures/jf2/note-slug-provided-unslugified.jf2
deleted file mode 100644
index 40e0389e..00000000
--- a/helpers/fixtures/jf2/note-slug-provided-unslugified.jf2
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "type": "entry",
-  "content": "I ate a cheese sandwich, which was nice.",
-  "mp-slug": "Cheese sandwich"
-}
diff --git a/helpers/fixtures/jf2/note-slug-provided.jf2 b/helpers/fixtures/jf2/note-slug-provided.jf2
deleted file mode 100644
index afaec458..00000000
--- a/helpers/fixtures/jf2/note-slug-provided.jf2
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "type": "entry",
-  "content": "I ate a cheese sandwich, which was nice.",
-  "mp-slug": "cheese-sandwich"
-}
diff --git a/packages/endpoint-media/lib/file.js b/packages/endpoint-media/lib/file.js
index 69dc4034..b2f7de72 100644
--- a/packages/endpoint-media/lib/file.js
+++ b/packages/endpoint-media/lib/file.js
@@ -1,4 +1,4 @@
-import { getDate, randomString } from "@indiekit/util";
+import { getDate } from "@indiekit/util";
 import { fileTypeFromBuffer } from "file-type";
 
 /**
@@ -7,26 +7,19 @@ import { fileTypeFromBuffer } from "file-type";
  * @param {object} file - Original file object
  * @returns {Promise<object>} File properties
  * @example fileData('brighton-pier.jpg') => {
- *   basename: 'ds48s',
  *   ext: '.jpg'
- *   filename: 'ds48s.jpg'
- *   originalname: 'brighton-pier.jpg',
+ *   filename: 'brighton-pier.jpg',
  *   'content-type': image/jpeg,
  *   published: '2020-07-19T22:59:23.497Z',
  * }
  */
 export const getFileProperties = async (timeZone, file) => {
-  const basename = randomString(5)
-    .replace(/-|_/, "0") // Don’t use common slug separator characters
-    .toLowerCase();
   const { ext } = await fileTypeFromBuffer(file.data);
   const published = getPublishedProperty(timeZone);
 
   return {
-    basename,
     ext,
-    filename: `${basename}.${ext}`,
-    originalname: file.name,
+    filename: file.name,
     "content-type": file.mimetype,
     published,
   };
diff --git a/packages/endpoint-media/lib/media-data.js b/packages/endpoint-media/lib/media-data.js
index 2dfdc974..d6fccf8b 100644
--- a/packages/endpoint-media/lib/media-data.js
+++ b/packages/endpoint-media/lib/media-data.js
@@ -18,7 +18,7 @@ export const mediaData = {
     debug(`create %O`, { file });
 
     const { hasDatabase, media, timeZone } = application;
-    const { me, postTypes } = publication;
+    const { me, postTypes, slugSeparator } = publication;
 
     // Media properties
     const properties = await getFileProperties(timeZone, file);
@@ -44,18 +44,19 @@ export const mediaData = {
       typeConfig.media.path,
       properties,
       application,
+      slugSeparator,
     );
     const url = await renderPath(
       typeConfig.media.url || typeConfig.media.path,
       properties,
       application,
+      slugSeparator,
     );
     properties.url = getCanonicalUrl(url, me);
 
     // Update media properties based on type configuration
     const urlPathSegment = properties.url.split("/");
     properties.filename = urlPathSegment.at(-1);
-    properties.basename = properties.filename.split(".")[0];
 
     const mediaData = { path, properties };
 
diff --git a/packages/endpoint-media/lib/utils.js b/packages/endpoint-media/lib/utils.js
index efcabfbc..efa98375 100644
--- a/packages/endpoint-media/lib/utils.js
+++ b/packages/endpoint-media/lib/utils.js
@@ -2,6 +2,7 @@ import {
   dateTokens,
   formatDate,
   getTimeZoneDesignator,
+  randomString,
   supplant,
 } from "@indiekit/util";
 import newbase60 from "newbase60";
@@ -27,9 +28,10 @@ export const getMediaProperties = (mediaData) => {
  * @param {string} path - URI template path
  * @param {object} properties - Media properties
  * @param {object} application - Application configuration
+ * @param {string} separator - Slug separator
  * @returns {Promise<string>} Path
  */
-export const renderPath = async (path, properties, application) => {
+export const renderPath = async (path, properties, application, separator) => {
   const dateObject = new Date(properties.published);
   const serverTimeZone = getTimeZoneDesignator();
   const { locale, timeZone } = application;
@@ -52,16 +54,21 @@ export const renderPath = async (path, properties, application) => {
   const count = await mediaTypeCount.get(application, properties);
   tokens.n = count + 1;
 
-  // Add slug token if 'mp-slug' property
-  if (properties["mp-slug"]) {
-    tokens.slug = properties["mp-slug"];
-  }
+  // Add random token
+  tokens.random = randomString(5)
+    .replace(separator, "0") // Don’t use slug separator character
+    .toLowerCase();
 
   // Add UUID token
   tokens.uuid = crypto.randomUUID();
 
+  // Add file extension
+  tokens.ext = properties.ext;
+
+  // Add file name
+  tokens.filename = properties.filename;
+
   // Populate URI template path with properties
-  tokens = { ...tokens, ...properties };
   path = supplant(path, tokens);
 
   return path;
diff --git a/packages/endpoint-micropub/lib/jf2.js b/packages/endpoint-micropub/lib/jf2.js
index 0638aaa4..145215c3 100644
--- a/packages/endpoint-micropub/lib/jf2.js
+++ b/packages/endpoint-micropub/lib/jf2.js
@@ -1,14 +1,9 @@
-import { getDate, randomString, slugify } from "@indiekit/util";
+import { getDate } from "@indiekit/util";
 import { mf2tojf2, mf2tojf2referenced } from "@paulrobertlloyd/mf2tojf2";
 import { fetchReferences } from "@paulrobertlloyd/mf2tojf2";
 import { markdownToHtml, htmlToMarkdown } from "./markdown.js";
 import { reservedProperties } from "./reserved-properties.js";
-import {
-  decodeQueryParameter,
-  excerptString,
-  relativeMediaPath,
-  toArray,
-} from "./utils.js";
+import { decodeQueryParameter, relativeMediaPath, toArray } from "./utils.js";
 
 /**
  * Create JF2 object from form-encoded request
@@ -68,7 +63,7 @@ export const mf2ToJf2 = async (body, requestReferences) => {
  * @returns {object} Normalised JF2 properties
  */
 export const normaliseProperties = (publication, properties, timeZone) => {
-  const { me, slugSeparator } = publication;
+  const { me } = publication;
 
   properties.published = getDate(timeZone, properties.published);
 
@@ -96,7 +91,9 @@ export const normaliseProperties = (publication, properties, timeZone) => {
     properties.video = getVideoProperty(properties, me);
   }
 
-  properties["mp-slug"] = getSlugProperty(properties, slugSeparator);
+  if (properties["mp-slug"]) {
+    properties.slug = properties["mp-slug"];
+  }
 
   if (properties["mp-syndicate-to"]) {
     properties["mp-syndicate-to"] = toArray(properties["mp-syndicate-to"]);
@@ -225,30 +222,6 @@ export const getVideoProperty = (properties, me) => {
   }));
 };
 
-/**
- * Get slug
- * @param {object} properties - JF2 properties
- * @param {string} separator - Slug separator
- * @returns {string} Array containing slug value
- */
-export const getSlugProperty = (properties, separator) => {
-  const suggested = properties["mp-slug"];
-  const { name } = properties;
-
-  let string;
-  if (suggested) {
-    string = suggested;
-  } else if (name) {
-    string = excerptString(name, 5);
-  } else {
-    string = randomString(5)
-      .replace("_", "0") // Slugify function strips any leading underscore
-      .replace(separator, "0"); // Don’t include slug separator character
-  }
-
-  return slugify(string, { separator });
-};
-
 /**
  * Get `mp-syndicate-to` property
  * @param {object} properties - JF2 properties
diff --git a/packages/endpoint-micropub/lib/post-data.js b/packages/endpoint-micropub/lib/post-data.js
index de7b5e15..56a738b8 100644
--- a/packages/endpoint-micropub/lib/post-data.js
+++ b/packages/endpoint-micropub/lib/post-data.js
@@ -22,7 +22,7 @@ export const postData = {
     debug(`create %O`, { draftMode, properties });
 
     const { hasDatabase, posts, timeZone } = application;
-    const { me, postTypes, syndicationTargets } = publication;
+    const { me, postTypes, slugSeparator, syndicationTargets } = publication;
 
     // Add syndication targets
     const syndicateTo = getSyndicateToProperty(properties, syndicationTargets);
@@ -48,6 +48,7 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
+      slugSeparator,
     );
     const url = await renderPath(typeConfig.post.url, properties, application);
     properties.url = getCanonicalUrl(url, me);
@@ -102,7 +103,7 @@ export const postData = {
     debug(`update ${url} %O`, { operation });
 
     const { posts, timeZone } = application;
-    const { me, postTypes } = publication;
+    const { me, postTypes, slugSeparator } = publication;
 
     // Read properties
     let { path: _originalPath, properties } = await this.read(application, url);
@@ -143,11 +144,13 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
+      slugSeparator,
     );
     const updatedUrl = await renderPath(
       typeConfig.post.url,
       properties,
       application,
+      slugSeparator,
     );
     properties.url = getCanonicalUrl(updatedUrl, me);
 
@@ -180,7 +183,7 @@ export const postData = {
     debug(`delete ${url}`);
 
     const { posts, timeZone } = application;
-    const { postTypes } = publication;
+    const { postTypes, slugSeparator } = publication;
 
     // Read properties
     const { properties } = await this.read(application, url);
@@ -207,6 +210,7 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
+      slugSeparator,
     );
 
     // Update data in posts collection
@@ -231,7 +235,7 @@ export const postData = {
     debug(`undelete ${url} %O`, { draftMode });
 
     const { posts } = application;
-    const { postTypes } = publication;
+    const { postTypes, slugSeparator } = publication;
 
     // Read deleted properties
     const { _deletedProperties } = await this.read(application, url);
@@ -248,6 +252,7 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
+      slugSeparator,
     );
 
     // Post status
diff --git a/packages/endpoint-micropub/lib/utils.js b/packages/endpoint-micropub/lib/utils.js
index ee3b17b3..ec64369c 100644
--- a/packages/endpoint-micropub/lib/utils.js
+++ b/packages/endpoint-micropub/lib/utils.js
@@ -3,6 +3,8 @@ import {
   formatDate,
   getTimeZoneDesignator,
   isDate,
+  randomString,
+  slugify,
   supplant,
 } from "@indiekit/util";
 import newbase60 from "newbase60";
@@ -41,6 +43,29 @@ export const excerptString = (string, n) => {
   }
 };
 
+/**
+ * Get slug
+ * @param {object} properties - JF2 properties
+ * @param {string} separator - Slug separator
+ * @returns {string} Slug
+ */
+export const getSlug = (properties, separator) => {
+  const { name, slug } = properties;
+
+  let string;
+  if (slug) {
+    string = slug;
+  } else if (name) {
+    string = excerptString(name, 5);
+  } else {
+    string = randomString(5)
+      .replace("_", "0") // Slugify function strips any leading underscore
+      .replace(separator, "0"); // Don’t use slug separator character
+  }
+
+  return slugify(string, { separator });
+};
+
 /**
  * Get post template properties
  * @param {object} properties - JF2 properties
@@ -78,9 +103,10 @@ export const relativeMediaPath = (url, me) =>
  * @param {string} path - URI template path
  * @param {object} properties - JF2 properties
  * @param {object} application - Application configuration
+ * @param {string} separator - Slug separator
  * @returns {Promise<string>} Path
  */
-export const renderPath = async (path, properties, application) => {
+export const renderPath = async (path, properties, application, separator) => {
   const dateObject = new Date(properties.published);
   const serverTimeZone = getTimeZoneDesignator();
   const { locale, timeZone } = application;
@@ -103,16 +129,18 @@ export const renderPath = async (path, properties, application) => {
   const count = await postTypeCount.get(application, properties);
   tokens.n = count + 1;
 
-  // Add slug token if 'mp-slug' property
-  if (properties["mp-slug"]) {
-    tokens.slug = properties["mp-slug"];
-  }
+  // Add random token
+  tokens.random = randomString(5)
+    .replace(separator, "0") // Don’t use slug separator character
+    .toLowerCase();
 
   // Add UUID token
   tokens.uuid = crypto.randomUUID();
 
+  // Add slug token (falls back to name or random if no `mp-slug`)
+  tokens.slug = getSlug(properties, separator);
+
   // Populate URI template path with properties
-  tokens = { ...tokens, ...properties };
   path = supplant(path, tokens);
 
   return path;


Test patch:
diff --git a/packages/endpoint-media/test/unit/file.js b/packages/endpoint-media/test/unit/file.js
index dd0d13a7..d7248ebb 100644
--- a/packages/endpoint-media/test/unit/file.js
+++ b/packages/endpoint-media/test/unit/file.js
@@ -24,9 +24,8 @@ describe("endpoint-media/lib/file", () => {
     };
     const result = await getFileProperties("UTC", file);
 
-    assert.equal(result.originalname, "photo.jpg");
     assert.equal(result.ext, "jpg");
-    assert.match(result.filename, /\w{5}.jpg/g);
+    assert.equal(result.filename, "photo.jpg");
     assert.equal(isValid(parseISO(result.published)), true);
   });
 });
diff --git a/packages/endpoint-media/test/unit/utils.js b/packages/endpoint-media/test/unit/utils.js
index d5344c35..3b5bfd4f 100644
--- a/packages/endpoint-media/test/unit/utils.js
+++ b/packages/endpoint-media/test/unit/utils.js
@@ -9,17 +9,17 @@ describe("endpoint-media/lib/util", () => {
   });
 
   it("Renders path from URI template and properties", async () => {
-    const template = "{yyyy}/{MM}/{uuid}/{slug}";
+    const template = "{yyyy}/{MM}/{uuid}/{random}/{filename}";
     const properties = {
       published: "2020-01-01",
-      "mp-slug": "foo",
+      filename: "foo.jpg",
     };
     const application = {};
-    const result = await renderPath(template, properties, application);
+    const result = await renderPath(template, properties, application, "-");
 
     assert.match(
       result,
-      /\d{4}\/\d{2}\/[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\/foo/,
+      /\d{4}\/\d{2}\/[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\/\w{5}\/foo\.jpg/,
     );
   });
 });
diff --git a/packages/endpoint-micropub/test/unit/jf2.js b/packages/endpoint-micropub/test/unit/jf2.js
index 7a285472..3771b804 100644
--- a/packages/endpoint-micropub/test/unit/jf2.js
+++ b/packages/endpoint-micropub/test/unit/jf2.js
@@ -10,7 +10,6 @@ import {
   getLocationProperty,
   getPhotoProperty,
   getVideoProperty,
-  getSlugProperty,
   getSyndicateToProperty,
   normaliseProperties,
 } from "../../lib/jf2.js";
@@ -287,49 +286,6 @@ describe("endpoint-micropub/lib/jf2", () => {
     ]);
   });
 
-  it("Derives slug from `mp-slug` property", () => {
-    const properties = JSON.parse(getFixture("jf2/note-slug-provided.jf2"));
-    const result = getSlugProperty(properties, "-");
-
-    assert.equal(result, "cheese-sandwich");
-  });
-
-  it("Derives slug from unslugified `mp-slug` property", () => {
-    const properties = JSON.parse(
-      getFixture("jf2/note-slug-provided-unslugified.jf2"),
-    );
-    const result = getSlugProperty(properties, "-");
-
-    assert.equal(result, "cheese-sandwich");
-  });
-
-  it("Derives slug, ignoring empty `mp-slug` property", () => {
-    const properties = JSON.parse(
-      getFixture("jf2/article-slug-provided-empty.jf2"),
-    );
-    const result = getSlugProperty(properties, "-");
-
-    assert.equal(result, "what-i-had-for-lunch");
-  });
-
-  it("Derives slug from `name` property", () => {
-    const properties = JSON.parse(
-      getFixture("jf2/article-content-provided-text.jf2"),
-    );
-    const result = getSlugProperty(properties, "-");
-
-    assert.equal(result, "what-i-had-for-lunch");
-  });
-
-  it("Derives slug by generating random string", () => {
-    const properties = JSON.parse(
-      getFixture("jf2/note-slug-missing-no-name.jf2"),
-    );
-    const result = getSlugProperty(properties, "-");
-
-    assert.match(result, /\w{5}/g);
-  });
-
   it("Does not add syndication target if no syndicators", () => {
     const properties = JSON.parse(
       getFixture("jf2/article-syndicate-to-provided.jf2"),
@@ -402,7 +358,7 @@ describe("endpoint-micropub/lib/jf2", () => {
 
     assert.equal(result.type, "entry");
     assert.equal(result.name, "What I had for lunch");
-    assert.equal(result["mp-slug"], "what-i-had-for-lunch");
+    assert.equal(result.slug, "lunch");
     assert.deepEqual(result.content, {
       html: `<blockquote>\n<p>I <del>ate</del><ins>had</ins> a <cite><a href="https://en.wikipedia.org/wiki/Cheese">cheese</a></cite> sandwich from https://cafe.example, which was &gt; 10.</p>\n</blockquote>\n<p>– Me, then.</p>`,
       text: "> I <del>ate</del><ins>had</ins> a <cite>[cheese](https://en.wikipedia.org/wiki/Cheese)</cite> sandwich from https://cafe.example, which was > 10.\n\n-- Me, then.",
diff --git a/packages/endpoint-micropub/test/unit/utils.js b/packages/endpoint-micropub/test/unit/utils.js
index dd5def37..34d229cb 100644
--- a/packages/endpoint-micropub/test/unit/utils.js
+++ b/packages/endpoint-micropub/test/unit/utils.js
@@ -3,6 +3,7 @@ import { before, describe, it, mock } from "node:test";
 import {
   decodeQueryParameter,
   excerptString,
+  getSlug,
   getPostTemplateProperties,
   relativeMediaPath,
   renderPath,
@@ -34,6 +35,37 @@ describe("endpoint-media/lib/utils", () => {
     assert.equal(result, "The quick fox jumped over");
   });
 
+  it("Derives slug from `slug` property", () => {
+    const properties = { slug: "cheese-sandwich" };
+    const result = getSlug(properties, "-");
+
+    assert.equal(result, "cheese-sandwich");
+  });
+
+  it("Derives slug from `name` property", () => {
+    const properties = { name: "Cheese sandwich" };
+    const result = getSlug(properties, "-");
+
+    assert.equal(result, "cheese-sandwich");
+  });
+
+  it("Derives slug, ignoring empty `slug` property", () => {
+    const properties = {
+      name: "What I had for lunch",
+      slug: "",
+    };
+    const result = getSlug(properties, "-");
+
+    assert.equal(result, "what-i-had-for-lunch");
+  });
+
+  it("Derives slug by generating random string", () => {
+    const properties = { content: "I ate a cheese sandwich, which was nice." };
+    const result = getSlug(properties, "-");
+
+    assert.match(result, /\w{5}/g);
+  });
+
   it("Gets post template properties", () => {
     const result = getPostTemplateProperties({
       name: "foo",
@@ -62,10 +94,10 @@ describe("endpoint-media/lib/utils", () => {
   });
 
   it("Renders path from URI template and properties", async () => {
-    const template = "{yyyy}/{MM}/{uuid}/{slug}";
+    const template = "{yyyy}/{MM}/{uuid}/{random}/{slug}";
     const properties = {
       published: "2020-01-01",
-      "mp-slug": "foo",
+      slug: "foo",
     };
     const application = {
       posts: {
@@ -83,11 +115,11 @@ describe("endpoint-media/lib/utils", () => {
         },
       },
     };
-    const result = await renderPath(template, properties, application);
+    const result = await renderPath(template, properties, application, "-");
 
     assert.match(
       result,
-      /\d{4}\/\d{2}\/[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\/foo/,
+      /\d{4}\/\d{2}\/[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\/\w{5}\/foo/,
     );
   });