Repository: getindiekit/indiekit
Pull request #726 (2024-03-29 23:39:56)

Refactor slugs
Argh. Slugs are a pain. They’re an essential component of the Micropub endpoint and need to be stable and predictable as they can affect generated paths (if a path don’t exist in a content store, things quickly fall apart).

This PR:

- [x] reverts aspects of #719, such that a `slug` property is always created and provided to the `postTemplate`; either from the value provided in `mp-slug`, a slugified `name` property, else a random 5-character string.
- [x] fixes the `slug` property not being remembered when a post is deleted
- [x] updates the post form to read from an existing `slug` property when editing a post
- [x] removes the `{random}` path token, as this token gets regenerated each time the `renderPath` function is called
- [x] removes the `{uuid}` path token for the same reasons
- [x] removes `slug` property from front matter when using the Eleventy present (Eleventy supplies `page.fileSlug`)
- [x] removes `slug` property from front matter when using the Jekyll present
- [x] removes `slug` property from front matter when using the Hugo present (with Hugo `slug` is a significant property that affects the published URL, which may lead to unintended URLs if the slug not being used in the post file path)

Need more use cases for it makes sense to supply `slug` property to front matter properties for different static site generators, however if using `postTemplate`, the `slug` property will be provided.

/cc @aciccarello


Patch:
diff --git a/docs/configuration/tokens.md b/docs/configuration/tokens.md
index 144271c9..3bca402f 100644
--- a/docs/configuration/tokens.md
+++ b/docs/configuration/tokens.md
@@ -51,8 +51,6 @@ 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). |
 
 ### Post file tokens
@@ -72,5 +70,3 @@ The following tokens are only available for media files:
 | `ext` | File extension of uploaded file, for example `jpg` |
 | `filename` | Slugified name of uploaded file, for example `flower_1.jpg` for a file with the original name `Flower 1.jpg`. |
 | `md5` | MD5 checksum of the uploaded file, for example `be7d321488de26f2eb38834af7162164` |
-
-[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 f76d05ad..3c78b3ff 100644
--- a/helpers/fixtures/jf2/article-content-provided.jf2
+++ b/helpers/fixtures/jf2/article-content-provided.jf2
@@ -1,6 +1,5 @@
 {
   "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.",
-  "mp-slug": "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."
 }
diff --git a/helpers/fixtures/jf2/article-slug-provided-empty.jf2 b/helpers/fixtures/jf2/article-slug-provided-empty.jf2
new file mode 100644
index 00000000..092afe4a
--- /dev/null
+++ b/helpers/fixtures/jf2/article-slug-provided-empty.jf2
@@ -0,0 +1,6 @@
+{
+  "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
new file mode 100644
index 00000000..c6375e3a
--- /dev/null
+++ b/helpers/fixtures/jf2/note-slug-missing-no-name.jf2
@@ -0,0 +1,4 @@
+{
+  "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
new file mode 100644
index 00000000..40e0389e
--- /dev/null
+++ b/helpers/fixtures/jf2/note-slug-provided-unslugified.jf2
@@ -0,0 +1,5 @@
+{
+  "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
new file mode 100644
index 00000000..afaec458
--- /dev/null
+++ b/helpers/fixtures/jf2/note-slug-provided.jf2
@@ -0,0 +1,5 @@
+{
+  "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 335b8781..297a3d07 100644
--- a/packages/endpoint-media/lib/file.js
+++ b/packages/endpoint-media/lib/file.js
@@ -1,26 +1,31 @@
-import { getDate } from "@indiekit/util";
+import path from "node:path";
+import { getDate, slugify } from "@indiekit/util";
 import { fileTypeFromBuffer } from "file-type";
 
 /**
  * Derive properties from file data
- * @param {object} timeZone - Application time zone
+ * @param {object} publication - Publication configuration
  * @param {object} file - Original file object
+ * @param {object} timeZone - Application time zone
  * @returns {Promise<object>} File properties
- * @example fileData('brighton-pier.jpg') => {
+ * @example fileData('Brighton Pier.jpg') => {
  *   ext: '.jpg'
  *   filename: 'brighton-pier.jpg',
  *   'content-type': image/jpeg,
  *   published: '2020-07-19T22:59:23.497Z',
  * }
  */
-export const getFileProperties = async (timeZone, file) => {
+export const getFileProperties = async (publication, file, timeZone) => {
   const { ext } = await fileTypeFromBuffer(file.data);
   const published = getPublishedProperty(timeZone);
 
+  let basename = path.basename(file.name, ext);
+  basename = slugify(basename, publication.slugSeparator);
+
   return {
     "content-type": file.mimetype,
     ext,
-    filename: file.name,
+    filename: `${basename}.${ext}`,
     md5: file.md5,
     published,
   };
diff --git a/packages/endpoint-media/lib/media-data.js b/packages/endpoint-media/lib/media-data.js
index d6fccf8b..9432f9f8 100644
--- a/packages/endpoint-media/lib/media-data.js
+++ b/packages/endpoint-media/lib/media-data.js
@@ -18,10 +18,10 @@ export const mediaData = {
     debug(`create %O`, { file });
 
     const { hasDatabase, media, timeZone } = application;
-    const { me, postTypes, slugSeparator } = publication;
+    const { me, postTypes } = publication;
 
     // Media properties
-    const properties = await getFileProperties(timeZone, file);
+    const properties = await getFileProperties(publication, file, timeZone);
 
     // Get post type configuration
     const type = await getMediaType(file);
@@ -44,13 +44,11 @@ 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);
 
diff --git a/packages/endpoint-media/lib/utils.js b/packages/endpoint-media/lib/utils.js
index 51b11b67..1f6b038b 100644
--- a/packages/endpoint-media/lib/utils.js
+++ b/packages/endpoint-media/lib/utils.js
@@ -1,10 +1,7 @@
-import { basename as getBasename } from "node:path";
 import {
   dateTokens,
   formatDate,
   getTimeZoneDesignator,
-  randomString,
-  slugify,
   supplant,
 } from "@indiekit/util";
 import newbase60 from "newbase60";
@@ -30,10 +27,9 @@ 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, separator) => {
+export const renderPath = async (path, properties, application) => {
   const dateObject = new Date(properties.published);
   const serverTimeZone = getTimeZoneDesignator();
   const { locale, timeZone } = application;
@@ -56,21 +52,11 @@ export const renderPath = async (path, properties, application, separator) => {
   const count = await mediaTypeCount.get(application, properties);
   tokens.n = count + 1;
 
-  // 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 token
   tokens.ext = properties.ext;
 
-  // Add slugified file name token
-  let basename = getBasename(properties.filename, properties.ext);
-  basename = slugify(basename, separator);
-  tokens.filename = `${basename}.${properties.ext}`;
+  // Add file name token
+  tokens.filename = properties.filename;
 
   // Add md5 token
   tokens.md5 = properties.md5;
diff --git a/packages/endpoint-micropub/lib/jf2.js b/packages/endpoint-micropub/lib/jf2.js
index 145215c3..9c9993a8 100644
--- a/packages/endpoint-micropub/lib/jf2.js
+++ b/packages/endpoint-micropub/lib/jf2.js
@@ -1,9 +1,14 @@
-import { getDate } from "@indiekit/util";
+import { getDate, randomString, slugify } 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, relativeMediaPath, toArray } from "./utils.js";
+import {
+  decodeQueryParameter,
+  excerptString,
+  relativeMediaPath,
+  toArray,
+} from "./utils.js";
 
 /**
  * Create JF2 object from form-encoded request
@@ -63,7 +68,7 @@ export const mf2ToJf2 = async (body, requestReferences) => {
  * @returns {object} Normalised JF2 properties
  */
 export const normaliseProperties = (publication, properties, timeZone) => {
-  const { me } = publication;
+  const { me, slugSeparator } = publication;
 
   properties.published = getDate(timeZone, properties.published);
 
@@ -91,9 +96,7 @@ export const normaliseProperties = (publication, properties, timeZone) => {
     properties.video = getVideoProperty(properties, me);
   }
 
-  if (properties["mp-slug"]) {
-    properties.slug = properties["mp-slug"];
-  }
+  properties.slug = getSlugProperty(properties, slugSeparator);
 
   if (properties["mp-syndicate-to"]) {
     properties["mp-syndicate-to"] = toArray(properties["mp-syndicate-to"]);
@@ -222,6 +225,30 @@ 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 f5e01eb2..f2127c92 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, slugSeparator, syndicationTargets } = publication;
+    const { me, postTypes, syndicationTargets } = publication;
 
     // Add syndication targets
     const syndicateTo = getSyndicateToProperty(properties, syndicationTargets);
@@ -48,14 +48,8 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
-      slugSeparator,
-    );
-    const url = await renderPath(
-      typeConfig.post.url,
-      properties,
-      application,
-      slugSeparator,
     );
+    const url = await renderPath(typeConfig.post.url, properties, application);
     properties.url = getCanonicalUrl(url, me);
 
     // Post status
@@ -108,7 +102,7 @@ export const postData = {
     debug(`update ${url} %O`, { operation });
 
     const { posts, timeZone } = application;
-    const { me, postTypes, slugSeparator } = publication;
+    const { me, postTypes } = publication;
 
     // Read properties
     let { path: _originalPath, properties } = await this.read(application, url);
@@ -149,13 +143,11 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
-      slugSeparator,
     );
     const updatedUrl = await renderPath(
       typeConfig.post.url,
       properties,
       application,
-      slugSeparator,
     );
     properties.url = getCanonicalUrl(updatedUrl, me);
 
@@ -188,7 +180,7 @@ export const postData = {
     debug(`delete ${url}`);
 
     const { posts, timeZone } = application;
-    const { postTypes, slugSeparator } = publication;
+    const { postTypes } = publication;
 
     // Read properties
     const { properties } = await this.read(application, url);
@@ -198,7 +190,7 @@ export const postData = {
 
     // Delete all properties, except those required for path creation
     for (const key in _deletedProperties) {
-      if (!["mp-slug", "post-type", "published", "type", "url"].includes(key)) {
+      if (!["post-type", "published", "slug", "type", "url"].includes(key)) {
         delete properties[key];
       }
     }
@@ -215,7 +207,6 @@ export const postData = {
       typeConfig.post.path,
       properties,
       application,
-      slugSeparator,
     );
 
     // Update data in posts collection
@@ -240,7 +231,7 @@ export const postData = {
     debug(`undelete ${url} %O`, { draftMode });
 
     const { posts } = application;
-    const { postTypes, slugSeparator } = publication;
+    const { postTypes } = publication;
 
     // Read deleted properties
     const { _deletedProperties } = await this.read(application, url);
@@ -257,7 +248,6 @@ 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 ec64369c..0b8ad687 100644
--- a/packages/endpoint-micropub/lib/utils.js
+++ b/packages/endpoint-micropub/lib/utils.js
@@ -3,8 +3,6 @@ import {
   formatDate,
   getTimeZoneDesignator,
   isDate,
-  randomString,
-  slugify,
   supplant,
 } from "@indiekit/util";
 import newbase60 from "newbase60";
@@ -43,29 +41,6 @@ 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
@@ -103,10 +78,9 @@ 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, separator) => {
+export const renderPath = async (path, properties, application) => {
   const dateObject = new Date(properties.published);
   const serverTimeZone = getTimeZoneDesignator();
   const { locale, timeZone } = application;
@@ -129,16 +103,8 @@ export const renderPath = async (path, properties, application, separator) => {
   const count = await postTypeCount.get(application, properties);
   tokens.n = count + 1;
 
-  // 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);
+  // Add slug token
+  tokens.slug = properties.slug;
 
   // Populate URI template path with properties
   path = supplant(path, tokens);
diff --git a/packages/endpoint-posts/views/post-form.njk b/packages/endpoint-posts/views/post-form.njk
index e03c0959..1eca3597 100644
--- a/packages/endpoint-posts/views/post-form.njk
+++ b/packages/endpoint-posts/views/post-form.njk
@@ -81,7 +81,7 @@
     {{ input({
       classes: "input--width-25",
       name: "mp-slug",
-      value: fieldData("mp-slug").value,
+      value: fieldData("slug").value,
       label: __("posts.form.mp-slug.label"),
       optional: true
     }) | indent(4) }}
diff --git a/packages/frontend/lib/filters/url.js b/packages/frontend/lib/filters/url.js
index 20aabfd6..13850a19 100644
--- a/packages/frontend/lib/filters/url.js
+++ b/packages/frontend/lib/filters/url.js
@@ -10,7 +10,8 @@ export const friendlyUrl = (string) => {
 
   try {
     const { host, pathname } = new URL(string);
-    return host + pathname;
+
+    return pathname === "/" ? host : host + pathname;
   } catch {
     return string;
   }
diff --git a/packages/indiekit/locales/de.json b/packages/indiekit/locales/de.json
index e04de33d..2650f7fa 100644
--- a/packages/indiekit/locales/de.json
+++ b/packages/indiekit/locales/de.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Melde dich mit IndieAuth an, um zu verifizieren, dass du %s besitzt",
+      "description": "Melden Sie sich an, um zu überprüfen, ob Sie Eigentümer sind %s",
       "me": "Webadresse",
-      "submit": "Mit IndieAuth anmelden",
+      "submit": "Fortsetzen",
       "title": "Anmelden"
     },
     "logout": {
diff --git a/packages/indiekit/locales/en.json b/packages/indiekit/locales/en.json
index 7b54697a..18840ff8 100644
--- a/packages/indiekit/locales/en.json
+++ b/packages/indiekit/locales/en.json
@@ -11,9 +11,9 @@
   "session": {
     "login": {
       "title": "Sign in",
-      "description": "Sign in with IndieAuth to verify that you own %s",
+      "description": "Sign in to verify that you own %s",
       "me": "Web address",
-      "submit": "Sign in with IndieAuth"
+      "submit": "Continue"
     },
     "logout": {
       "title": "Sign out"
diff --git a/packages/indiekit/locales/es-419.json b/packages/indiekit/locales/es-419.json
index fe6255d0..d440308d 100644
--- a/packages/indiekit/locales/es-419.json
+++ b/packages/indiekit/locales/es-419.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Inicie sesión con IndieAuth para verificar que es propietario de %s",
+      "description": "Inicia sesión para verificar que eres dueño de %s",
       "me": "Dirección web",
-      "submit": "Iniciar sesión con IndieAuth",
+      "submit": "Continuar",
       "title": "Iniciar sesión"
     },
     "logout": {
diff --git a/packages/indiekit/locales/es.json b/packages/indiekit/locales/es.json
index c1dfa2d6..40807b02 100644
--- a/packages/indiekit/locales/es.json
+++ b/packages/indiekit/locales/es.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Inicia sesión con IndieAuth para verificar que eres propietario de %s",
+      "description": "Inicia sesión para verificar que eres dueño de %s",
       "me": "Dirección web",
-      "submit": "Iniciar sesión con IndieAuth",
+      "submit": "Continuar",
       "title": "Iniciar sesión"
     },
     "logout": {
diff --git a/packages/indiekit/locales/fr.json b/packages/indiekit/locales/fr.json
index 245cedb2..1bd859ae 100644
--- a/packages/indiekit/locales/fr.json
+++ b/packages/indiekit/locales/fr.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Connectez-vous avec IndieAuth pour vérifier que vous possédez %s",
+      "description": "Connectez-vous pour vérifier que vous possédez %s",
       "me": "Adresse Web",
-      "submit": "Connectez-vous avec IndieAuth",
+      "submit": "Continuer",
       "title": "Se connecter"
     },
     "logout": {
diff --git a/packages/indiekit/locales/id.json b/packages/indiekit/locales/id.json
index 0633f44e..4d7477a2 100644
--- a/packages/indiekit/locales/id.json
+++ b/packages/indiekit/locales/id.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Masuk dengan IndieAuth untuk memverifikasi bahwa Anda memiliki %s",
+      "description": "Masuk untuk memverifikasi bahwa Anda memiliki %s",
       "me": "Alamat web",
-      "submit": "Masuk dengan IndieAuth",
+      "submit": "Lanjutkan",
       "title": "Masuk"
     },
     "logout": {
diff --git a/packages/indiekit/locales/nl.json b/packages/indiekit/locales/nl.json
index 91c122e1..c69a3381 100644
--- a/packages/indiekit/locales/nl.json
+++ b/packages/indiekit/locales/nl.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Meld u aan met IndieAuth om te controleren of u eigenaar bent van %s",
+      "description": "Meld je aan om te verifiëren dat je de eigenaar bent %s",
       "me": "Webadres",
-      "submit": "Log in met IndieAuth",
+      "submit": "Doorgaan",
       "title": "Inloggen"
     },
     "logout": {
diff --git a/packages/indiekit/locales/pl.json b/packages/indiekit/locales/pl.json
index 2a24ae9a..81037ead 100644
--- a/packages/indiekit/locales/pl.json
+++ b/packages/indiekit/locales/pl.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Zaloguj się za pomocą IndieAuth, aby sprawdzić, czy posiadasz %s",
+      "description": "Zaloguj się, aby sprawdzić, czy jesteś właścicielem %s",
       "me": "Adres sieci Web",
-      "submit": "Zaloguj się za pomocą IndieAuth",
+      "submit": "Kontynuuj",
       "title": "Zaloguj się"
     },
     "logout": {
diff --git a/packages/indiekit/locales/pt.json b/packages/indiekit/locales/pt.json
index bfb63244..7214fcd4 100644
--- a/packages/indiekit/locales/pt.json
+++ b/packages/indiekit/locales/pt.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Faça login com IndieAuth para verificar se você possui %s",
+      "description": "Faça login para verificar se você possui %s",
       "me": "Endereço Web",
-      "submit": "Faça login com IndieAuth",
+      "submit": "Continuar",
       "title": "Fazer login"
     },
     "logout": {
diff --git a/packages/indiekit/locales/sr.json b/packages/indiekit/locales/sr.json
index f753d9cb..34f3f745 100644
--- a/packages/indiekit/locales/sr.json
+++ b/packages/indiekit/locales/sr.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "Prijavi se pomoću IndieAuth da potvrdiš da poseduješ %s",
+      "description": "Prijavite se da biste potvrdili da ste vlasnik %s",
       "me": "Veb adresa\n",
-      "submit": "Prijavi se pomoću IndieAuth",
+      "submit": "Nastavi",
       "title": "Prijavi se"
     },
     "logout": {
diff --git a/packages/indiekit/locales/zh-Hans-CN.json b/packages/indiekit/locales/zh-Hans-CN.json
index c8a715eb..5e62e302 100644
--- a/packages/indiekit/locales/zh-Hans-CN.json
+++ b/packages/indiekit/locales/zh-Hans-CN.json
@@ -55,9 +55,9 @@
   },
   "session": {
     "login": {
-      "description": "使用 IndieAuth 登录以验证你是否拥有 %s",
+      "description": "登录以验证您是否拥有 %s",
       "me": "网址",
-      "submit": "使用 IndieAuth 登录",
+      "submit": "继续",
       "title": "登录"
     },
     "logout": {
diff --git a/packages/preset-eleventy/lib/post-template.js b/packages/preset-eleventy/lib/post-template.js
index 7c21351f..8b3049b9 100644
--- a/packages/preset-eleventy/lib/post-template.js
+++ b/packages/preset-eleventy/lib/post-template.js
@@ -53,6 +53,7 @@ const getFrontMatter = (properties) => {
   delete properties.name; // Use `title`
   delete properties.postStatus; // Use `draft`
   delete properties.published; // Use `date`
+  delete properties.slug; // use `page.fileSlug`
   delete properties.type; // Not required
   delete properties.url; // Not required
 
diff --git a/packages/preset-hugo/lib/post-template.js b/packages/preset-hugo/lib/post-template.js
index d4f419cb..b17f6a14 100644
--- a/packages/preset-hugo/lib/post-template.js
+++ b/packages/preset-hugo/lib/post-template.js
@@ -58,6 +58,7 @@ const getFrontMatter = (properties, frontMatterFormat) => {
   delete properties.name; // Use `title`
   delete properties.postStatus; // Use `draft`
   delete properties.published; // Use `date`
+  delete properties.slug; // File path dictates slug
   delete properties.type; // Not required
   delete properties.updated; // Use `lastmod`
   delete properties.url; // Not required
diff --git a/packages/preset-jekyll/lib/post-template.js b/packages/preset-jekyll/lib/post-template.js
index bf1f8f80..41dc9375 100644
--- a/packages/preset-jekyll/lib/post-template.js
+++ b/packages/preset-jekyll/lib/post-template.js
@@ -54,6 +54,7 @@ const getFrontMatter = (properties) => {
   delete properties.content; // Shown below front matter
   delete properties.name; // Use `title`
   delete properties.post_status; // Use `published`
+  delete properties.slug; // File path dictates slug
   delete properties.summary; // Use `excerpt`
   delete properties.type; // Not required
   delete properties.url; // Not required


Test patch:
diff --git a/packages/endpoint-media/test/unit/file.js b/packages/endpoint-media/test/unit/file.js
index 7d8608f8..bf9c7283 100644
--- a/packages/endpoint-media/test/unit/file.js
+++ b/packages/endpoint-media/test/unit/file.js
@@ -18,15 +18,18 @@ describe("endpoint-media/lib/file", () => {
   });
 
   it("Derives properties from file data", async () => {
+    const publication = {
+      slugSeparator: "-",
+    };
     const file = {
       data: getFixture("file-types/photo.jpg", false),
-      name: "photo.jpg",
+      name: "Photo 1.jpg",
       md5: "be7d321488de26f2eb38834af7162164",
     };
-    const result = await getFileProperties("UTC", file);
+    const result = await getFileProperties(publication, file, "UTC");
 
     assert.equal(result.ext, "jpg");
-    assert.equal(result.filename, "photo.jpg");
+    assert.equal(result.filename, "photo-1.jpg");
     assert.equal(result.md5, "be7d321488de26f2eb38834af7162164");
     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 df161171..6c9ebe92 100644
--- a/packages/endpoint-media/test/unit/utils.js
+++ b/packages/endpoint-media/test/unit/utils.js
@@ -5,7 +5,7 @@ import { renderPath } from "../../lib/utils.js";
 describe("endpoint-media/lib/util", () => {
   const properties = {
     ext: "jpg",
-    filename: "Foo 1.jpg",
+    filename: "foo-1.jpg",
     md5: "be7d321488de26f2eb38834af7162164",
     published: "2020-01-01",
   };
@@ -16,16 +16,12 @@ describe("endpoint-media/lib/util", () => {
   });
 
   it("Renders path from URI template and properties", async () => {
-    const dateToken = await renderPath("{yyyy}/{MM}", properties, {}, "-");
-    const fileToken = await renderPath("{filename}", properties, {}, "-");
-    const uuidToken = await renderPath("{uuid}", properties, {}, "-");
-    const randomToken = await renderPath("{random}", properties, {}, "-");
-    const md5Token = await renderPath("{md5}", properties, {}, "-");
+    const dateToken = await renderPath("{yyyy}/{MM}", properties, {});
+    const fileToken = await renderPath("{filename}", properties, {});
+    const md5Token = await renderPath("{md5}", properties, {});
 
     assert.match(dateToken, /^\d{4}\/\d{2}/);
     assert.match(fileToken, /^foo-1\.jpg/);
-    assert.match(uuidToken, /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}/);
-    assert.match(randomToken, /^\w{5}/);
     assert.match(md5Token, /^([\da-f]{32}|[\dA-F]{32})$/);
   });
 });
diff --git a/packages/endpoint-micropub/test/unit/jf2.js b/packages/endpoint-micropub/test/unit/jf2.js
index 3771b804..d7bf0997 100644
--- a/packages/endpoint-micropub/test/unit/jf2.js
+++ b/packages/endpoint-micropub/test/unit/jf2.js
@@ -10,6 +10,7 @@ import {
   getLocationProperty,
   getPhotoProperty,
   getVideoProperty,
+  getSlugProperty,
   getSyndicateToProperty,
   normaliseProperties,
 } from "../../lib/jf2.js";
@@ -286,6 +287,49 @@ 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"),
@@ -358,7 +402,7 @@ describe("endpoint-micropub/lib/jf2", () => {
 
     assert.equal(result.type, "entry");
     assert.equal(result.name, "What I had for lunch");
-    assert.equal(result.slug, "lunch");
+    assert.equal(result.slug, "what-i-had-for-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/post-data.js b/packages/endpoint-micropub/test/unit/post-data.js
index f26657ef..5a1475e5 100644
--- a/packages/endpoint-micropub/test/unit/post-data.js
+++ b/packages/endpoint-micropub/test/unit/post-data.js
@@ -52,7 +52,7 @@ describe("endpoint-micropub/lib/post-data", async () => {
     const result = await postData.create(application, publication, properties);
 
     assert.equal(result.properties["post-type"], "note");
-    assert.equal(result.properties["mp-slug"], "foo");
+    assert.equal(result.properties.slug, "foo");
     assert.equal(result.properties.type, "entry");
     assert.equal(result.properties.url, "https://website.example/notes/foo/");
   });
diff --git a/packages/endpoint-micropub/test/unit/utils.js b/packages/endpoint-micropub/test/unit/utils.js
index 34d229cb..1efe8b8e 100644
--- a/packages/endpoint-micropub/test/unit/utils.js
+++ b/packages/endpoint-micropub/test/unit/utils.js
@@ -3,7 +3,6 @@ import { before, describe, it, mock } from "node:test";
 import {
   decodeQueryParameter,
   excerptString,
-  getSlug,
   getPostTemplateProperties,
   relativeMediaPath,
   renderPath,
@@ -35,37 +34,6 @@ 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",
@@ -94,7 +62,7 @@ describe("endpoint-media/lib/utils", () => {
   });
 
   it("Renders path from URI template and properties", async () => {
-    const template = "{yyyy}/{MM}/{uuid}/{random}/{slug}";
+    const template = "{yyyy}/{MM}/{slug}";
     const properties = {
       published: "2020-01-01",
       slug: "foo",
@@ -115,12 +83,9 @@ 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}\/\w{5}\/foo/,
-    );
+    assert.match(result, /\d{4}\/\d{2}\/foo/);
   });
 
   it("Convert string to array if not already an array", () => {
diff --git a/packages/frontend/test/unit/filters/url.js b/packages/frontend/test/unit/filters/url.js
index dc021c50..126fc76c 100644
--- a/packages/frontend/test/unit/filters/url.js
+++ b/packages/frontend/test/unit/filters/url.js
@@ -5,6 +5,7 @@ import { friendlyUrl, imageUrl } from "../../../lib/filters/index.js";
 describe("frontend/lib/filters/url", () => {
   it("Gets friendly URL", () => {
     assert.equal(friendlyUrl("https://foo.example/bar"), "foo.example/bar");
+    assert.equal(friendlyUrl("https://foo.example"), "foo.example");
     assert.equal(friendlyUrl("foo.example/bar"), "foo.example/bar");
   });