Repository: getindiekit/indiekit
Pull request #679 (2024-01-10 00:15:44)

Add default (and show fields based on) supported post type properties
* Move responsibility for defining default post types from publication presets to Indiekit’s default configuration.
* Add `h`, `fields` and `requiredFields` keys to `publication.postTypes[]`; these are used to dictate which fields are shown for a given post type, in what order and which are required.
* Use schema validation for post type fields, and save these under `validationSchema` on `publication.postTypes[]` (which in turn calls individual schemas from `@indiekit/util`).
* Supply `h`, `properties` and `required-properties` values when querying the Micropub endpoint, adding support for https://github.com/indieweb/micropub-extensions/issues/33
* Add custom post types to post type discovery
* Add a (yet to be localised) `jam` post type (which initiated this rethinking of how post types are added and displayed).

A future PR will move these post type definitions into separate modules, allowing plugin authors to add their own post types beyond those installed by default by Indiekit. 


Patch:
diff --git a/docs/configuration/post-types.md b/docs/configuration/post-types.md
index 0365a200..12063d41 100644
--- a/docs/configuration/post-types.md
+++ b/docs/configuration/post-types.md
@@ -35,14 +35,17 @@ For example, to use the Jekyll preset but override the `note` and `photo` post t
 
 Each post type can take the following values:
 
-| Name | Description |
-| :--- | :---------- |
-| `type` | The IndieWeb [post type](https://indieweb.org/Category:PostType). _Required_. |
-| `name` | The name you use for this post type on your own site. You needn’t specify this value, but some Micropub clients use it in their publishing interfaces. _Required_. |
-| `post.path` | Where posts should be saved in your content store. _Required_. |
-| `post.url` | Permalink (the URL path) for posts on your website. _Required_. |
-| `media.path` | Where media files should be saved in your content store. _Required_ for `photo`, `video` and `audio` post types only. |
-| `media.url` | Public accessible URL for media files. This can use the same template variables as `media.path`. _Optional_, defaults to `media.path`. |
+| Property | Type | Description |
+| :--- | :---------- | :---------- |
+| `type` | `string` | The IndieWeb [post type](https://indieweb.org/Category:PostType). _Required_. |
+| `name` | `string` | The name you use for this post type on your own site. You needn’t specify this value, but some Micropub clients use this value in their publishing interfaces. |
+| `fields` | `Array[string]` | Which input fields to display in Indiekit’s publishing interface. Other Micropub clients may use these values in their publishing interfaces. |
+| `requiredFields` | `Array[string]` | Which input fields are required. Other Micropub clients may use these values in their publishing interfaces. |
+| `validationSchema` | `Array[object]` | [Validation schema for express-validator](https://express-validator.github.io/docs/guides/schema-validation). Used to validate input fields in Indiekit’s own publishing interface. |
+| `post.path` | `string` | Where posts should be saved in your content store. _Required_. |
+| `post.url` | `string` | Permalink (the URL path) for posts on your website. _Required_. |
+| `media.path` | `string` | Where media files should be saved in your content store. _Required_ |
+| `media.url` | `string` | Public accessible URL for media files. This can use the same template variables as `media.path`. _Optional_, defaults to `media.path`. |
 
 ## Path and URL tokens
 
diff --git a/docs/specifications.md b/docs/specifications.md
index 5f75e76d..58cdb963 100644
--- a/docs/specifications.md
+++ b/docs/specifications.md
@@ -57,6 +57,7 @@ The following [scopes](https://indieweb.org/scope) are supported:
 
 * [x] [Category](https://github.com/indieweb/micropub-extensions/issues/5) query
 * [x] [Post list](https://github.com/indieweb/micropub-extensions/issues/4) query
+* [x] [Supported properties](https://github.com/indieweb/micropub-extensions/issues/33) query
 * [x] [Supported queries](https://github.com/indieweb/micropub-extensions/issues/7) query
 * [x] [Supported vocabulary](https://github.com/indieweb/micropub-extensions/issues/1) query
 * [x] [Media source](https://github.com/indieweb/micropub-extensions/issues/14) query, including [filtering by URL parameter](https://github.com/indieweb/micropub-extensions/issues/37)[^1]
diff --git a/helpers/config/index.js b/helpers/config/index.js
index d939e3f3..6f446d4a 100644
--- a/helpers/config/index.js
+++ b/helpers/config/index.js
@@ -24,6 +24,7 @@ export const testConfig = async (options) => {
     {
       type: "note",
       name: "Custom note post type",
+      fields: ["content", "geo"],
       post: {
         path: "src/content/notes/{slug}.md",
         url: "notes/{slug}/",
@@ -32,6 +33,7 @@ export const testConfig = async (options) => {
     {
       type: "photo",
       name: "Custom photo post type",
+      fields: ["photo"],
       post: {
         path: "src/content/photos/{slug}.md",
         url: "photos/{slug}/",
diff --git a/indiekit.config.js b/indiekit.config.js
index 350099cf..54bf5caf 100644
--- a/indiekit.config.js
+++ b/indiekit.config.js
@@ -28,10 +28,20 @@ const config = {
       {
         type: "like",
         name: "Favourite",
+        fields: ["like-of", "published"],
+        requiredFields: ["like-of", "published"],
       },
       {
-        type: "watch",
-        name: "Film",
+        type: "jam",
+        name: "Jam",
+        discovery: "jam-of",
+        h: "entry",
+        fields: ["jam-of", "content", "post-status", "published"],
+        requiredFields: ["jam-of", "published"],
+        post: {
+          path: "_jams/{yyyy}-{MM}-{dd}-{slug}.md",
+          url: "jams/{yyyy}/{MM}/{dd}/{slug}",
+        },
       },
     ],
   },
diff --git a/packages/endpoint-micropub/lib/config.js b/packages/endpoint-micropub/lib/config.js
index 77580383..8b25fe0d 100644
--- a/packages/endpoint-micropub/lib/config.js
+++ b/packages/endpoint-micropub/lib/config.js
@@ -32,6 +32,9 @@ export const getConfig = (application, publication) => {
     "post-types": postTypes.map((postType) => ({
       type: postType.type,
       name: postType.name,
+      h: postType.h,
+      properties: postType.fields,
+      "required-properties": postType.requiredFields,
     })),
     "syndicate-to": syndicateTo,
     q,
diff --git a/packages/endpoint-micropub/lib/post-data.js b/packages/endpoint-micropub/lib/post-data.js
index c1721887..dd3cab81 100644
--- a/packages/endpoint-micropub/lib/post-data.js
+++ b/packages/endpoint-micropub/lib/post-data.js
@@ -29,7 +29,7 @@ export const postData = {
     properties = normaliseProperties(publication, properties, timeZone);
 
     // Post type
-    const type = getPostType(properties);
+    const type = getPostType(postTypes, properties);
     properties["post-type"] = type;
 
     // Get post type configuration
@@ -125,7 +125,7 @@ export const postData = {
     properties = normaliseProperties(publication, properties, timeZone);
 
     // Post type
-    const type = getPostType(properties);
+    const type = getPostType(postTypes, properties);
     const typeConfig = getPostTypeConfig(type, postTypes);
     properties["post-type"] = type;
 
diff --git a/packages/endpoint-micropub/lib/post-type-discovery.js b/packages/endpoint-micropub/lib/post-type-discovery.js
index 671039c1..cedb4ce5 100644
--- a/packages/endpoint-micropub/lib/post-type-discovery.js
+++ b/packages/endpoint-micropub/lib/post-type-discovery.js
@@ -1,36 +1,36 @@
 /**
  * Accepts a JF2 object and attempts to determine the post type
+ * @param {object} postTypes - Configured post types
  * @param {object} properties - JF2 properties
  * @returns {string|null} The post type or null if unknown
+ * @see {@link https://ptd.spec.indieweb.org/#algorithm}
  */
-export const getPostType = (properties) => {
+export const getPostType = (postTypes, properties) => {
   const propertiesMap = new Map(Object.entries(properties));
 
-  // If already has a type that’s not entry, return that value
-  if (properties.type && properties.type !== "entry") {
+  // If post has the event type, it’s an event
+  if (properties.type && properties.type === "event") {
     return properties.type;
   }
 
-  // Then continue to base post type discovery
   const basePostTypes = new Map();
+
+  // Types defined in Post Type Discovery specification
   basePostTypes.set("rsvp", "rsvp");
-  basePostTypes.set("in-reply-to", "reply");
-  basePostTypes.set("repost-of", "repost");
-  basePostTypes.set("bookmark-of", "bookmark");
-  basePostTypes.set("quotation-of", "quotation");
-  basePostTypes.set("like-of", "like");
-  basePostTypes.set("checkin", "checkin");
-  basePostTypes.set("jam-of", "jam");
-  basePostTypes.set("listen-of", "listen");
-  basePostTypes.set("read-of", "read");
-  basePostTypes.set("watch-of", "watch");
+  basePostTypes.set("repost", "repost-of");
+  basePostTypes.set("like", "like-of");
+  basePostTypes.set("reply", "in-reply-to");
   basePostTypes.set("video", "video");
-  basePostTypes.set("audio", "audio");
   basePostTypes.set("photo", "photo");
 
+  // Types defined in post type configuration
+  for (const postType of postTypes) {
+    basePostTypes.set(postType.type, postType.discovery);
+  }
+
   for (const basePostType of basePostTypes) {
-    if (propertiesMap.has(basePostType[0])) {
-      return basePostType[1];
+    if (propertiesMap.has(basePostType[1])) {
+      return basePostType[0];
     }
   }
 
diff --git a/packages/endpoint-posts/includes/endpoint-posts-widget.njk b/packages/endpoint-posts/includes/endpoint-posts-widget.njk
index d7865b72..79fc673f 100644
--- a/packages/endpoint-posts/includes/endpoint-posts-widget.njk
+++ b/packages/endpoint-posts/includes/endpoint-posts-widget.njk
@@ -7,9 +7,8 @@
     {{ button({
       classes: "button--secondary-on-offset button--inline",
       href: application.postsEndpoint + "/create/?type=" + config.type,
-      icon: config.type,
-      text: config.name
-    }) if application.supportedPostTypes | includes(config.type) }}
+      text: (icon(config.type) or icon("note")) + config.name
+    }) }}
   {% endfor %}</div>
 {% else %}
   {{ prose({
diff --git a/packages/endpoint-posts/includes/post-fields/audio.njk b/packages/endpoint-posts/includes/post-fields/audio.njk
new file mode 100644
index 00000000..78d8c848
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/audio.njk
@@ -0,0 +1,23 @@
+{% macro audioFieldset(index) %}
+  {{ input({
+    name: "audio[" + index + "]",
+    type: "url",
+    value: fieldData("audio[" + index + "]").value,
+    label: __("posts.form.media.label"),
+    attributes: {
+      placeholder: "https://"
+    },
+    errorMessage: fieldData("audio[" + index + "]").errorMessage
+  }) | indent(2) }}
+{% endmacro %}
+
+{% call addAnother({
+  fieldset: { legend: __("posts.form.audio.label") },
+  name: __("posts.form.audio.name")
+}) %}
+  {% for audio in fieldData("audio").value %}
+    {{ audioFieldset(loop.index0) }}
+  {% else %}
+    {{ audioFieldset(0) }}
+  {% endfor %}
+{% endcall %}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/bookmark-of.njk b/packages/endpoint-posts/includes/post-fields/bookmark-of.njk
new file mode 100644
index 00000000..2284a474
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/bookmark-of.njk
@@ -0,0 +1,11 @@
+{{ input({
+  name: "bookmark-of",
+  type: "url",
+  value: fieldData("bookmark-of").value,
+  label: __("posts.form.bookmark-of.label"),
+  optional: not requiredFields | includes("bookmark-of"),
+  attributes: {
+    placeholder: "https://"
+  },
+  errorMessage: fieldData("bookmark-of").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/category.njk b/packages/endpoint-posts/includes/post-fields/category.njk
new file mode 100644
index 00000000..8a88e854
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/category.njk
@@ -0,0 +1,11 @@
+{{ tagInput({
+  name: "category",
+  value: fieldData("category").value,
+  label: __("posts.form.category.label"),
+  hint: __("posts.form.category.hint"),
+  optional: true,
+  localisation: {
+    tag: __("posts.form.category.tag"),
+    tags: __("posts.form.category.label") | lower
+  }
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/content.njk b/packages/endpoint-posts/includes/post-fields/content.njk
new file mode 100644
index 00000000..c676ccca
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/content.njk
@@ -0,0 +1,7 @@
+{{ textarea({
+  name: "content",
+  value: properties.content.text or fieldData("content").value,
+  label: __("posts.form.content.label"),
+  optional: not requiredFields | includes("content"),
+  errorMessage: fieldData("content").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/geo.njk b/packages/endpoint-posts/includes/post-fields/geo.njk
new file mode 100644
index 00000000..c53cee46
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/geo.njk
@@ -0,0 +1,8 @@
+{{ geoInput({
+  name: "geo",
+  value: geo,
+  label: __("posts.form.geo.label"),
+  hint: __("posts.form.geo.hint", "50.8211, -0.1452"),
+  optional: not requiredFields | includes("geo"),
+  errorMessage: fieldData("geo").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/in-reply-to.njk b/packages/endpoint-posts/includes/post-fields/in-reply-to.njk
new file mode 100644
index 00000000..a14c5226
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/in-reply-to.njk
@@ -0,0 +1,11 @@
+{{ input({
+  name: "in-reply-to",
+  type: "url",
+  value: fieldData("in-reply-to").value,
+  label: __("posts.form.in-reply-to.label"),
+  optional: not requiredFields | includes("in-reply-to"),
+  attributes: {
+    placeholder: "https://"
+  },
+  errorMessage: fieldData("in-reply-to").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/jam-of.njk b/packages/endpoint-posts/includes/post-fields/jam-of.njk
new file mode 100644
index 00000000..72d0f3ec
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/jam-of.njk
@@ -0,0 +1,34 @@
+{% call fieldset({
+  classes: "fieldset--group",
+  legend: __("posts.form.jam-of.label")
+}) %}
+  {{ input({
+    name: "jam-of[type]",
+    type: "hidden",
+    value: "cite"
+  }) | indent(2) }}
+
+  {{ input({
+    name: "jam-of[url]",
+    type: "url",
+    value: fieldData("jam-of.url").value,
+    label: __("posts.form.cite.url"),
+    errorMessage: fieldData("jam-of.url").errorMessage
+  }) | indent(2) }}
+
+  {{ input({
+    name: "jam-of[name]",
+    type: "text",
+    value: fieldData("jam-of.name").value,
+    label: __("posts.form.cite.name"),
+    errorMessage: fieldData("jam-of.name").errorMessage
+  }) | indent(2) }}
+
+  {{ input({
+    name: "jam-of[author]",
+    type: "text",
+    value: fieldData("jam-of.author").value,
+    label: __("posts.form.cite.author"),
+    errorMessage: fieldData("jam-of.author").errorMessage
+  }) | indent(2) }}
+{% endcall %}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/like-of.njk b/packages/endpoint-posts/includes/post-fields/like-of.njk
new file mode 100644
index 00000000..368c28b1
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/like-of.njk
@@ -0,0 +1,11 @@
+{{ input({
+  name: "like-of",
+  type: "url",
+  value: fieldData("like-of").value,
+  label: __("posts.form.like-of.label"),
+  optional: not requiredFields | includes("like-of"),
+  attributes: {
+    placeholder: "https://"
+  },
+  errorMessage: fieldData("like-of").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/location.njk b/packages/endpoint-posts/includes/post-fields/location.njk
new file mode 100644
index 00000000..bf0e96a9
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/location.njk
@@ -0,0 +1,42 @@
+{% call fieldset({
+  classes: "fieldset--group",
+  legend: __("posts.form.location.label"),
+  optional: not requiredFields | includes("location")
+}) %}
+  {{ input({
+    name: "location[name]",
+    value: fieldData("location.name").value,
+    label: __("posts.form.location.name")
+  }) | indent(2) }}
+
+  {{ input({
+    name: "location[street-address]",
+    value: fieldData("location.street-address").value,
+    label: __("posts.form.location.street-address"),
+    autocomplete: "address-line1"
+  }) | indent(2) }}
+
+  {{ input({
+    classes: "input--width-25",
+    name: "location[locality]",
+    value: fieldData("location.locality").value,
+    label: __("posts.form.location.locality"),
+    autocomplete: "address-level2"
+  }) | indent(2) }}
+
+  {{ input({
+    classes: "input--width-25",
+    name: "location[country-name]",
+    value: fieldData("location.country-name").value,
+    label: __("posts.form.location.country-name"),
+    autocomplete: "country-name"
+  }) | indent(2) }}
+
+  {{ input({
+    classes: "input--width-10",
+    name: "location[postal-code]",
+    value: fieldData("location.postal-code").value,
+    label: __("posts.form.location.postal-code"),
+    autocomplete: "postal-code"
+  }) | indent(2) }}
+{% endcall %}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/name.njk b/packages/endpoint-posts/includes/post-fields/name.njk
new file mode 100644
index 00000000..e67b749d
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/name.njk
@@ -0,0 +1,7 @@
+{{ input({
+  name: "name",
+  value: fieldData("name").value,
+  label: __("posts.form.name.label"),
+  optional: not requiredFields | includes("name"),
+  errorMessage: fieldData("name").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/photo.njk b/packages/endpoint-posts/includes/post-fields/photo.njk
new file mode 100644
index 00000000..e24dabc2
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/photo.njk
@@ -0,0 +1,35 @@
+{% macro photoFieldset(index) %}
+{% call fieldset({
+  classes: "fieldset--group"
+}) %}
+  {{ input({
+    name: "photo[" + index + "][url]",
+    type: "url",
+    value: fieldData("photo[" + index + "].url").value,
+    label: __("posts.form.media.label"),
+    attributes: {
+      placeholder: "https://"
+    },
+    errorMessage: fieldData("photo[" + index + "].url").errorMessage
+  }) | indent(2) }}
+
+  {{ textarea({
+    name: "photo[" + index + "][alt]",
+    value: fieldData("photo[" + index + "].alt").value,
+    label: __("posts.form.mp-photo-alt.label"),
+    rows: 1,
+    errorMessage: fieldData("photo[" + index + "].alt").errorMessage
+  }) | indent(2) }}
+{% endcall %}
+{% endmacro %}
+
+{% call addAnother({
+  fieldset: { legend: __("posts.form.photo.label") },
+  name: __("posts.form.photo.name")
+}) %}
+  {% for photo in fieldData("photo").value %}
+    {{ photoFieldset(loop.index0) }}
+  {% else %}
+    {{ photoFieldset(0) }}
+  {% endfor %}
+{% endcall %}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/repost-of.njk b/packages/endpoint-posts/includes/post-fields/repost-of.njk
new file mode 100644
index 00000000..7e8a6e83
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/repost-of.njk
@@ -0,0 +1,11 @@
+{{ input({
+  name: "repost-of",
+  type: "url",
+  value: fieldData("repost-of").value,
+  label: __("posts.form.repost-of.label"),
+  optional: not requiredFields | includes("repost-of"),
+  attributes: {
+    placeholder: "https://"
+  },
+  errorMessage: fieldData("repost-of").errorMessage
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/rsvp.njk b/packages/endpoint-posts/includes/post-fields/rsvp.njk
new file mode 100644
index 00000000..5a93ec4d
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/rsvp.njk
@@ -0,0 +1,22 @@
+{{ radios({
+  inline: true,
+  name: "rsvp",
+  values: properties.rsvp or "yes",
+  fieldset: {
+    legend: __("posts.form.rsvp.label")
+  },
+  optional: not requiredFields | includes("rsvp"),
+  items: [{
+    label: __("posts.form.rsvp.yes"),
+    value: "yes"
+  }, {
+    label: __("posts.form.rsvp.no"),
+    value: "no"
+  }, {
+    label: __("posts.form.rsvp.maybe"),
+    value: "maybe"
+  }, {
+    label: __("posts.form.rsvp.interested"),
+    value: "interested"
+  }]
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/start.njk b/packages/endpoint-posts/includes/post-fields/start.njk
new file mode 100644
index 00000000..c0cf7801
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/start.njk
@@ -0,0 +1,33 @@
+{% call fieldset({
+  classes: "fieldset--group",
+  legend: __("posts.form.event.label")
+}) %}
+  <event-duration>
+    {{ input({
+      name: "start",
+      type: "datetime-local",
+      value: fieldData("start").value,
+      label: __("posts.form.event.start"),
+      optional: not requiredFields | includes("start"),
+      errorMessage: fieldData("start").errorMessage
+    }) | indent(2) }}
+
+    {{ input({
+      name: "end",
+      type: "datetime-local",
+      value: fieldData("end").value,
+      label: __("posts.form.event.end"),
+      optional: not requiredFields | includes("end"),
+      errorMessage: fieldData("end").errorMessage
+    }) | indent(2) }}
+
+    {{ checkboxes({
+      name: "all-day",
+      items: [{
+        label: __("posts.form.event.all-day"),
+        value: "true",
+        checked: allDay
+      }]
+    }) | indent(2) }}
+  </event-duration>
+{% endcall %}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/summary.njk b/packages/endpoint-posts/includes/post-fields/summary.njk
new file mode 100644
index 00000000..885b1bd7
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/summary.njk
@@ -0,0 +1,7 @@
+{{ textarea({
+  name: "summary",
+  value: properties.summary,
+  label: __("posts.form.summary.label"),
+  optional: not requiredFields | includes("summary"),
+  rows: 1
+}) }}
\ No newline at end of file
diff --git a/packages/endpoint-posts/includes/post-fields/video.njk b/packages/endpoint-posts/includes/post-fields/video.njk
new file mode 100644
index 00000000..bd43e75d
--- /dev/null
+++ b/packages/endpoint-posts/includes/post-fields/video.njk
@@ -0,0 +1,23 @@
+{% macro videoFieldset(index) %}
+  {{ input({
+    name: "video[" + index + "]",
+    type: "url",
+    value: fieldData("video[" + index + "]").value,
+    label: __("posts.form.media.label"),
+    attributes: {
+      placeholder: "https://"
+    },
+    errorMessage: fieldData("video[" + index + "]").errorMessage
+  }) | indent(2) }}
+{% endmacro %}
+
+{% call addAnother({
+  name: __("posts.form.video.name"),
+  fieldset: { legend: __("posts.form.video.label") }
+}) %}
+  {% for video in fieldData("video").value %}
+    {{ videoFieldset(loop.index0) }}
+  {% else %}
+    {{ videoFieldset(0) }}
+  {% endfor %}
+{% endcall %}
\ No newline at end of file
diff --git a/packages/endpoint-posts/index.js b/packages/endpoint-posts/index.js
index bee2b968..a45932aa 100644
--- a/packages/endpoint-posts/index.js
+++ b/packages/endpoint-posts/index.js
@@ -34,13 +34,13 @@ export default class PostsEndpoint {
     router.post("/new", newController.post);
 
     router.get("/create", postData.create, formController.get);
-    router.post("/create", postData.create, validate, formController.post);
+    router.post("/create", postData.create, validate(), formController.post);
 
     router.use("/:uid/:action?", postData.read);
     router.get("/:uid", postController);
 
     router.get("/:uid/update", formController.get);
-    router.post("/:uid/update", validate, formController.post);
+    router.post("/:uid/update", validate(), formController.post);
 
     router.get("/:uid/:action(delete|undelete)", deleteController.get);
     router.post("/:uid/:action(delete|undelete)", deleteController.post);
@@ -51,18 +51,5 @@ export default class PostsEndpoint {
   init(Indiekit) {
     Indiekit.addEndpoint(this);
     Indiekit.config.application.postsEndpoint = this.mountPath;
-    Indiekit.config.application.supportedPostTypes = [
-      "article",
-      "audio",
-      "note",
-      "bookmark",
-      "reply",
-      "like",
-      "photo",
-      "event",
-      "rsvp",
-      "repost",
-      "video",
-    ];
   }
 }
diff --git a/packages/endpoint-posts/lib/controllers/form.js b/packages/endpoint-posts/lib/controllers/form.js
index 98f8acd6..b043b1ec 100644
--- a/packages/endpoint-posts/lib/controllers/form.js
+++ b/packages/endpoint-posts/lib/controllers/form.js
@@ -16,8 +16,7 @@ export const formController = {
    * @type {import("express").RequestHandler}
    */
   async get(request, response) {
-    const { action, postsPath, postType, postTypeName, scope } =
-      response.locals;
+    const { action, postsPath, postType, name, scope } = response.locals;
 
     if (scope && checkScope(scope, action)) {
       return response.render("post-form", {
@@ -32,7 +31,7 @@ export const formController = {
               },
         title: response.locals.__(
           `posts.${action}.title`,
-          postTypeName.toLowerCase().replace("rsvp", "RSVP"),
+          name.toLowerCase().replace("rsvp", "RSVP"),
         ),
       });
     }
@@ -46,14 +45,14 @@ export const formController = {
    */
   async post(request, response) {
     const { micropubEndpoint, timeZone } = request.app.locals.application;
-    const { accessToken, action, postTypeName, properties } = response.locals;
+    const { accessToken, action, name, properties } = response.locals;
 
     const errors = validationResult(request);
     if (!errors.isEmpty()) {
       return response.status(422).render("post-form", {
         title: response.locals.__(
           `posts.${action}.title`,
-          postTypeName.toLowerCase().replace("rsvp", "RSVP"),
+          name.toLowerCase().replace("rsvp", "RSVP"),
         ),
         errors: errors.mapped(),
       });
@@ -105,7 +104,7 @@ export const formController = {
       // @todo Use `properties` for field names whose values should be submitted
       delete values["all-day"];
       delete values.geo;
-      delete values["post-type"];
+      delete values.postType;
       delete values["publication-date"];
 
       const mf2 = jf2ToMf2({ properties: sanitise(values) });
@@ -132,7 +131,7 @@ export const formController = {
       response.render("post-form", {
         title: response.locals.__(
           `posts.${action}.title`,
-          postTypeName.toLowerCase().replace("rsvp", "RSVP"),
+          name.toLowerCase().replace("rsvp", "RSVP"),
         ),
         error,
       });
diff --git a/packages/endpoint-posts/lib/controllers/new.js b/packages/endpoint-posts/lib/controllers/new.js
index 5184d0d3..642c2ca1 100644
--- a/packages/endpoint-posts/lib/controllers/new.js
+++ b/packages/endpoint-posts/lib/controllers/new.js
@@ -8,15 +8,12 @@ export const newController = {
    */
   async get(request, response) {
     const action = "create";
-    const { application, publication } = request.app.locals;
+    const { publication } = request.app.locals;
     const postsPath = path.dirname(request.baseUrl + request.path);
     const postType = request.query.type || "article";
     const { scope } = request.session;
 
     const postTypeItems = publication.postTypes
-      .filter((postType) =>
-        application.supportedPostTypes.includes(postType.type),
-      )
       .sort((a, b) => {
         return a.name.localeCompare(b.name);
       })
diff --git a/packages/endpoint-posts/lib/middleware/post-data.js b/packages/endpoint-posts/lib/middleware/post-data.js
index c6d896ba..ed4a85f4 100644
--- a/packages/endpoint-posts/lib/middleware/post-data.js
+++ b/packages/endpoint-posts/lib/middleware/post-data.js
@@ -5,7 +5,7 @@ import {
   getGeoValue,
   getPostName,
   getPostProperties,
-  getPostTypeName,
+  getPostTypeConfig,
   getSyndicateToItems,
 } from "../utils.js";
 
@@ -18,6 +18,12 @@ export const postData = {
     const postType = request.query.type || "note";
     const properties = request.body;
 
+    // Get post type config
+    const { name, fields, h, requiredFields } = getPostTypeConfig(
+      publication,
+      postType,
+    );
+
     // Only select ‘checked’ syndication targets on first view
     const checkTargets = Object.entries(request.body).length === 0;
 
@@ -28,13 +34,16 @@ export const postData = {
     response.locals = {
       accessToken: access_token,
       action: "create",
+      fields,
+      name,
       postsPath: path.dirname(request.baseUrl + request.path),
       postType,
-      postTypeName: getPostTypeName(publication, postType),
       properties,
       scope,
+      requiredFields,
       showAdvancedOptions,
       syndicationTargetItems: getSyndicateToItems(publication, checkTargets),
+      type: h,
       ...response.locals,
     };
 
@@ -61,18 +70,27 @@ export const postData = {
       const geo = properties?.location && getGeoValue(properties.location);
       const postType = properties["post-type"];
 
+      // Get post type config
+      const { name, fields, h, requiredFields } = getPostTypeConfig(
+        publication,
+        postType,
+      );
+
       response.locals = {
         accessToken: access_token,
         action: action || "create",
         allDay,
         draftMode: scope?.includes("draft"),
+        fields,
         geo,
+        h,
+        name,
         postName: getPostName(publication, properties),
         postsPath: path.dirname(request.baseUrl + request.path),
         postStatus: properties["post-status"],
         postType,
-        postTypeName: getPostTypeName(publication, postType),
         properties,
+        requiredFields,
         scope,
         showAdvancedOptions: true,
         syndicationTargetItems: getSyndicateToItems(publication),
diff --git a/packages/endpoint-posts/lib/middleware/validation.js b/packages/endpoint-posts/lib/middleware/validation.js
index 74458ade..aaff6c70 100644
--- a/packages/endpoint-posts/lib/middleware/validation.js
+++ b/packages/endpoint-posts/lib/middleware/validation.js
@@ -1,87 +1,24 @@
-import { check } from "express-validator";
-import { LAT_LONG_RE } from "../utils.js";
+import { checkSchema } from "express-validator";
 
-export const validate = [
-  check("audio.*")
-    .if((value, { req }) => req.body?.["post-type"] === "audio")
-    .notEmpty()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.media.empty`, "/music/audio.mp3"),
-    ),
-  check("bookmark-of")
-    .if((value, { req }) => req.body?.["post-type"] === "bookmark")
-    .exists()
-    .isURL()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.url.empty`, "https://example.org"),
-    ),
-  check("in-reply-to")
-    .if(
-      (value, { req }) =>
-        req.body?.["post-type"] === "reply" ||
-        req.body?.["post-type"] === "rsvp",
-    )
-    .exists()
-    .isURL()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.url.empty`, "https://example.org"),
-    ),
-  check("like-of")
-    .if((value, { req }) => req.body?.["post-type"] === "like")
-    .exists()
-    .isURL()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.url.empty`, "https://example.org"),
-    ),
-  check("repost-of")
-    .if((value, { req }) => req.body?.["post-type"] === "repost")
-    .exists()
-    .isURL()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.url.empty`, "https://example.org"),
-    ),
-  check("name")
-    .if(
-      (value, { req }) =>
-        req.body?.["post-type"] === "article" ||
-        req.body?.["post-type"] === "bookmark" ||
-        req.body?.["post-type"] === "event",
-    )
-    .notEmpty()
-    .withMessage((value, { req, path }) => req.__(`posts.error.${path}.empty`)),
-  check("start")
-    .if((value, { req }) => req.body?.["post-type"] === "event")
-    .notEmpty()
-    .withMessage((value, { req, path }) => req.__(`posts.error.${path}.empty`)),
-  check("content")
-    .if(
-      (value, { req }) =>
-        req.body?.["post-type"] === "article" ||
-        req.body?.["post-type"] === "note" ||
-        req.body?.["post-type"] === "reply",
-    )
-    .notEmpty()
-    .withMessage((value, { req, path }) => req.__(`posts.error.${path}.empty`)),
-  check("photo.*.url")
-    .if((value, { req }) => req.body?.["post-type"] === "photo")
-    .notEmpty()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.media.empty`, "/photos/image.jpg"),
-    ),
-  check("photo.*.alt")
-    .if((value, { req }) => req.body?.["post-type"] === "photo")
-    .notEmpty()
-    .withMessage((value, { req }) => req.__(`posts.error.mp-photo-alt.empty`)),
-  check("geo")
-    .if((value, { req }) => req.body?.geo)
-    .custom((value) => value.match(LAT_LONG_RE))
-    .withMessage((value, { req, path }) =>
-      req.__(`posts.error.${path}.invalid`),
-    ),
-  check("video.*")
-    .if((value, { req }) => req.body?.["post-type"] === "video")
-    .notEmpty()
-    .withMessage((value, { req }) =>
-      req.__(`posts.error.media.empty`, "/movies/video.mp4"),
-    ),
-];
+export const validate = () => {
+  return async (request, response, next) => {
+    const { postTypes } = request.app.locals.publication;
+    const validations = [];
+
+    for (const postType of postTypes) {
+      if (!postType.validationSchema) {
+        continue;
+      }
+
+      for (const schema of postType.validationSchema) {
+        validations.push(checkSchema(schema));
+      }
+    }
+
+    for (let validation of validations) {
+      await validation.run(request);
+    }
+
+    next();
+  };
+};
diff --git a/packages/endpoint-posts/lib/utils.js b/packages/endpoint-posts/lib/utils.js
index ada88801..92022fdb 100644
--- a/packages/endpoint-posts/lib/utils.js
+++ b/packages/endpoint-posts/lib/utils.js
@@ -1,20 +1,17 @@
 import { Buffer } from "node:buffer";
-import { sanitise } from "@indiekit/util";
+import { sanitise, ISO_6709_RE } from "@indiekit/util";
 import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2";
 import formatcoords from "formatcoords";
 import { endpoint } from "./endpoint.js";
 import { statusTypes } from "./status-types.js";
 
-export const LAT_LONG_RE =
-  /^(?<latitude>(?:-?|\+?)?\d+(?:\.\d+)?),\s*(?<longitude>(?:-?|\+?)?\d+(?:\.\d+)?)$/;
-
 /**
  * Get geographic coordinates property
  * @param {string} geo - Latitude and longitude, comma separated
  * @returns {object} JF2 geo location property
  */
 export const getGeoProperty = (geo) => {
-  const { latitude, longitude } = geo.match(LAT_LONG_RE).groups;
+  const { latitude, longitude } = geo.match(ISO_6709_RE).groups;
 
   return {
     type: "geo",
@@ -107,7 +104,9 @@ export const getPostName = (publication, properties) => {
     return properties.name;
   }
 
-  return getPostTypeName(publication, properties["post-type"]);
+  const { name } = getPostTypeConfig(publication, properties["post-type"]);
+
+  return name;
 };
 
 /**
@@ -133,18 +132,18 @@ export const getPostProperties = async (uid, micropubEndpoint, accessToken) => {
 };
 
 /**
- * Get post type name
+ * Get post type config
  * @param {object} publication - Publication configuration
  * @param {string} postType - Post type
- * @returns {string} Post type name
+ * @returns {object} Post type configuration
  */
-export const getPostTypeName = (publication, postType) => {
+export const getPostTypeConfig = (publication, postType) => {
   if (publication.postTypes && postType) {
     const postTypeConfig = publication.postTypes.find(
       (item) => item.type === postType,
     );
 
-    return postTypeConfig.name;
+    return postTypeConfig;
   }
 
   return "";
diff --git a/packages/endpoint-posts/locales/en.json b/packages/endpoint-posts/locales/en.json
index 96445df6..602cf873 100644
--- a/packages/endpoint-posts/locales/en.json
+++ b/packages/endpoint-posts/locales/en.json
@@ -75,6 +75,11 @@
         "label": "Categories",
         "tag": "category"
       },
+      "cite": {
+        "name": "Name",
+        "author": "Author",
+        "url": "URL"
+      },
       "content": {
         "label": "Content"
       },
@@ -94,6 +99,9 @@
       "like-of": {
         "label": "Like of"
       },
+      "jam-of": {
+        "label": "Jam of"
+      },
       "location": {
         "label": "Location",
         "name": "Venue",
diff --git a/packages/endpoint-posts/views/post-form.njk b/packages/endpoint-posts/views/post-form.njk
index 61ebc80a..0f80ca13 100644
--- a/packages/endpoint-posts/views/post-form.njk
+++ b/packages/endpoint-posts/views/post-form.njk
@@ -1,293 +1,21 @@
 {% extends "form.njk" %}
 
-{% set contentOptional =
-  postType !== "article" and
-  postType !== "note" and
-  postType !== "reply" %}
-{% set contentIgnored =
-  postType === "like"
-%}
-{% set nameOptional =
-  postType !== "article" and
-  postType !== "bookmark" and
-  postType !== "event" %}
-{% set nameIgnored =
-  postType === "note" or
-  postType === "like" or
-  postType === "reply" or
-  postType === "rsvp"
-%}
-
-{% macro audioFieldset(index) %}
-  {{ input({
-    name: "audio[" + index + "]",
-    type: "url",
-    value: fieldData("audio[" + index + "]").value,
-    label: __("posts.form.media.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("audio[" + index + "]").errorMessage
-  }) | indent(2) }}
-{% endmacro %}
-
-{% macro photoFieldset(index) %}
-{% call fieldset({
-  classes: "fieldset--group"
-}) %}
-  {{ input({
-    name: "photo[" + index + "][url]",
-    type: "url",
-    value: fieldData("photo[" + index + "].url").value,
-    label: __("posts.form.media.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("photo[" + index + "].url").errorMessage
-  }) | indent(2) }}
-
-  {{ textarea({
-    name: "photo[" + index + "][alt]",
-    value: fieldData("photo[" + index + "].alt").value,
-    label: __("posts.form.mp-photo-alt.label"),
-    rows: 1,
-    errorMessage: fieldData("photo[" + index + "].alt").errorMessage
-  }) | indent(2) }}
-{% endcall %}
-{% endmacro %}
-
-{% macro videoFieldset(index) %}
-  {{ input({
-    name: "video[" + index + "]",
-    type: "url",
-    value: fieldData("video[" + index + "]").value,
-    label: __("posts.form.media.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("video[" + index + "]").errorMessage
-  }) | indent(2) }}
-{% endmacro %}
-
 {% block fieldset %}
   {{ input({
     name: "type",
     type: "hidden",
-    value: "event" if postType === "event" else "entry"
+    value: type
   }) | indent(2) }}
 
   {{ input({
-    name: "post-type",
+    name: "postType",
     type: "hidden",
     value: postType
   }) | indent(2) }}
 
-  {{ input({
-    name: "bookmark-of",
-    type: "url",
-    value: fieldData("bookmark-of").value,
-    label: __("posts.form.bookmark-of.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("bookmark-of").errorMessage
-  }) | indent(2) if postType === "bookmark" }}
-
-  {{ input({
-    name: "in-reply-to",
-    type: "url",
-    value: fieldData("in-reply-to").value,
-    label: __("posts.form.in-reply-to.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("in-reply-to").errorMessage
-  }) | indent(2) if postType === "reply" or postType === "rsvp" }}
-
-  {{ radios({
-    inline: true,
-    name: "rsvp",
-    values: properties.rsvp or "yes",
-    fieldset: {
-      legend: __("posts.form.rsvp.label")
-    },
-    items: [{
-      label: __("posts.form.rsvp.yes"),
-      value: "yes"
-    }, {
-      label: __("posts.form.rsvp.no"),
-      value: "no"
-    }, {
-      label: __("posts.form.rsvp.maybe"),
-      value: "maybe"
-    }, {
-      label: __("posts.form.rsvp.interested"),
-      value: "interested"
-    }]
-  }) | indent(2) if postType === "rsvp" }}
-
-  {{ input({
-    name: "like-of",
-    type: "url",
-    value: fieldData("like-of").value,
-    label: __("posts.form.like-of.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("like-of").errorMessage
-  }) | indent(2) if postType === "like" }}
-
-  {{ input({
-    name: "repost-of",
-    type: "url",
-    value: fieldData("repost-of").value,
-    label: __("posts.form.repost-of.label"),
-    attributes: {
-      placeholder: "https://"
-    },
-    errorMessage: fieldData("repost-of").errorMessage
-  }) | indent(2) if postType === "repost" }}
-
-  {{ input({
-    name: "name",
-    value: fieldData("name").value,
-    label: __("posts.form.name.label"),
-    optional: nameOptional,
-    errorMessage: fieldData("name").errorMessage
-  }) | indent(2) if not nameIgnored }}
-
-{% if postType === "event" %}
-{% call fieldset({
-  classes: "fieldset--group",
-  legend: __("posts.form.location.label"),
-  optional: true
-}) %}
-  {{ input({
-    name: "location[name]",
-    value: fieldData("location.name").value,
-    label: __("posts.form.location.name")
-  }) | indent(2) }}
-
-  {{ input({
-    name: "location[street-address]",
-    value: fieldData("location.street-address").value,
-    label: __("posts.form.location.street-address"),
-    autocomplete: "address-line1"
-  }) | indent(2) }}
-
-  {{ input({
-    classes: "input--width-25",
-    name: "location[locality]",
-    value: fieldData("location.locality").value,
-    label: __("posts.form.location.locality"),
-    autocomplete: "address-level2"
-  }) | indent(2) }}
-
-  {{ input({
-    classes: "input--width-25",
-    name: "location[country-name]",
-    value: fieldData("location.country-name").value,
-    label: __("posts.form.location.country-name"),
-    autocomplete: "country-name"
-  }) | indent(2) }}
-
-  {{ input({
-    classes: "input--width-10",
-    name: "location[postal-code]",
-    value: fieldData("location.postal-code").value,
-    label: __("posts.form.location.postal-code"),
-    autocomplete: "postal-code"
-  }) | indent(2) }}
-{% endcall %}
-
-{% call fieldset({
-  classes: "fieldset--group",
-  legend: __("posts.form.event.label")
-}) %}
-  <event-duration>
-    {{ input({
-      name: "start",
-      type: "datetime-local",
-      value: fieldData("start").value,
-      label: __("posts.form.event.start"),
-      errorMessage: fieldData("start").errorMessage
-    }) | indent(2) }}
-
-    {{ input({
-      name: "end",
-      type: "datetime-local",
-      value: fieldData("end").value,
-      label: __("posts.form.event.end"),
-      optional: true,
-      errorMessage: fieldData("end").errorMessage
-    }) | indent(2) }}
-
-    {{ checkboxes({
-      name: "all-day",
-      items: [{
-        label: __("posts.form.event.all-day"),
-        value: "true",
-        checked: allDay
-      }]
-    }) | indent(2) }}
-  </event-duration>
-{% endcall %}
-{% endif %}
-
-  {{ textarea({
-    name: "content",
-    value: properties.content.text or fieldData("content").value,
-    label: __("posts.form.content.label"),
-    optional: contentOptional,
-    errorMessage: fieldData("content").errorMessage
-  }) if not contentIgnored }}
-
-  {{ textarea({
-    name: "summary",
-    value: properties.summary,
-    label: __("posts.form.summary.label"),
-    optional: true,
-    rows: 1
-  }) if postType === "article" }}
-
-{% if postType === "audio" %}
-{% call addAnother({
-  fieldset: { legend: __("posts.form.audio.label") },
-  name: __("posts.form.audio.name")
-}) %}
-  {% for audio in fieldData("audio").value %}
-    {{ audioFieldset(loop.index0) }}
-  {% else %}
-    {{ audioFieldset(0) }}
-  {% endfor %}
-{% endcall %}
-{% endif %}
-
-{% if postType === "photo" %}
-{% call addAnother({
-  fieldset: { legend: __("posts.form.photo.label") },
-  name: __("posts.form.photo.name")
-}) %}
-  {% for photo in fieldData("photo").value %}
-    {{ photoFieldset(loop.index0) }}
-  {% else %}
-    {{ photoFieldset(0) }}
-  {% endfor %}
-{% endcall %}
-{% endif %}
-
-{% if postType === "video" %}
-{% call addAnother({
-  name: __("posts.form.video.name"),
-  fieldset: { legend: __("posts.form.video.label") }
-}) %}
-  {% for video in fieldData("video").value %}
-    {{ videoFieldset(loop.index0) }}
-  {% else %}
-    {{ videoFieldset(0) }}
-  {% endfor %}
-{% endcall %}
-{% endif %}
+{% for field in fields %}
+  {% include "post-fields/" + field + ".njk" ignore missing %}
+{% endfor %}
 
   {{ checkboxes({
     name: "mp-syndicate-to",
@@ -296,7 +24,7 @@
       legend: __("posts.form.mp-syndicate-to.label")
     },
     items: syndicationTargetItems
-  }) | indent(2) if syndicationTargetItems.length }}
+  }) | indent(2) }}
 
   {% call details({
     open: showAdvancedOptions,
@@ -323,6 +51,7 @@
         })
       }]
     }) if postStatus !== "published" else input({
+      classes: "input--width-10",
       name: "published",
       value: fieldData("published").value | localDate(application.timeZone),
       type: "datetime-local",
@@ -346,28 +75,7 @@
         label: __("posts.status.unlisted"),
         value: "unlisted"
       }]
-    }) | indent(4) }}
-
-    {{ tagInput({
-      name: "category",
-      value: fieldData("category").value,
-      label: __("posts.form.category.label"),
-      hint: __("posts.form.category.hint"),
-      optional: true,
-      localisation: {
-        tag: __("posts.form.category.tag"),
-        tags: __("posts.form.category.label") | lower
-      }
-    }) | indent(4) }}
-
-    {{ geoInput({
-      name: "geo",
-      value: geo,
-      label: __("posts.form.geo.label"),
-      hint: __("posts.form.geo.hint", "50.8211, -0.1452"),
-      optional: true,
-      errorMessage: fieldData("geo").errorMessage
-    }) | indent(4) }}
+    }) | indent(4) if fields | includes("visibility") }}
 
     {{ input({
       classes: "input--width-25",
diff --git a/packages/indiekit/config/defaults.js b/packages/indiekit/config/defaults.js
index 43c31ddd..c674238c 100644
--- a/packages/indiekit/config/defaults.js
+++ b/packages/indiekit/config/defaults.js
@@ -1,5 +1,6 @@
 import { createRequire } from "node:module";
 import process from "node:process";
+import { validationSchemas } from "@indiekit/util";
 import cookieSession from "cookie-session";
 // eslint-ignore import/order
 const require = createRequire(import.meta.url);
@@ -48,7 +49,240 @@ export const defaultConfig = {
     locale: "en",
     me: undefined,
     postTemplate: undefined,
-    postTypes: [],
+    postTypes: [
+      {
+        type: "article",
+        name: "Article",
+        h: "entry",
+        fields: [
+          "name",
+          "content",
+          "summary",
+          "category",
+          "geo",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["content", "name", "published"],
+        validationSchema: [
+          {
+            content: validationSchemas.content,
+            geo: validationSchemas.geo,
+            name: validationSchemas.name,
+          },
+        ],
+      },
+      {
+        type: "note",
+        name: "Note",
+        h: "entry",
+        fields: [
+          "content",
+          "category",
+          "geo",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["content", "published"],
+        validationSchema: [
+          {
+            content: validationSchemas.content,
+            geo: validationSchemas.geo,
+          },
+        ],
+      },
+      {
+        type: "photo",
+        name: "Photo",
+        h: "entry",
+        fields: [
+          "photo",
+          "content",
+          "category",
+          "geo",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["photo", "published", "mp-photo-alt"],
+        validationSchema: [
+          {
+            geo: validationSchemas.geo,
+            "photo.*.alt": validationSchemas["photo.*.alt"],
+            "photo.*.url": validationSchemas["photo.*.url"],
+          },
+        ],
+      },
+      {
+        type: "video",
+        name: "Video",
+        h: "entry",
+        fields: [
+          "video",
+          "content",
+          "category",
+          "geo",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["video", "published"],
+        validationSchema: [
+          {
+            geo: validationSchemas.geo,
+            "video.*": validationSchemas["video.*"],
+          },
+        ],
+      },
+      {
+        type: "audio",
+        name: "Audio",
+        discovery: "audio",
+        h: "entry",
+        fields: [
+          "audio",
+          "content",
+          "category",
+          "geo",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["audio", "published"],
+        validationSchema: [
+          {
+            "audio.*": validationSchemas["audio.*"],
+            geo: validationSchemas.geo,
+          },
+        ],
+      },
+      {
+        type: "bookmark",
+        name: "Bookmark",
+        discovery: "bookmark-of",
+        h: "entry",
+        fields: [
+          "bookmark-of",
+          "name",
+          "content",
+          "category",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["bookmark-of", "published"],
+        validationSchema: [
+          {
+            "bookmark-of": validationSchemas["bookmark-of"],
+          },
+        ],
+      },
+      {
+        type: "event",
+        name: "Event",
+        h: "event",
+        fields: [
+          "name",
+          "start",
+          "end",
+          "url",
+          "location",
+          "content",
+          "category",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["name", "start", "published"],
+        validationSchema: [
+          {
+            name: validationSchemas.name,
+            start: validationSchemas.start,
+          },
+        ],
+      },
+      {
+        type: "rsvp",
+        name: "RSVP",
+        h: "entry",
+        fields: [
+          "in-reply-to",
+          "rsvp",
+          "content",
+          "category",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["rsvp", "in-reply-to", "published"],
+        validationSchema: [
+          {
+            "in-reply-to": validationSchemas["in-reply-to"],
+            rsvp: validationSchemas.rsvp,
+          },
+        ],
+      },
+      {
+        type: "reply",
+        name: "Reply",
+        h: "entry",
+        fields: [
+          "in-reply-to",
+          "content",
+          "category",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["content", "in-reply-to", "published"],
+        validationSchema: [
+          {
+            content: validationSchemas.content,
+            "in-reply-to": validationSchemas["in-reply-to"],
+          },
+        ],
+      },
+      {
+        type: "repost",
+        name: "Repost",
+        h: "entry",
+        fields: [
+          "repost-of",
+          "content",
+          "category",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["repost-of", "published"],
+        validationSchema: [
+          {
+            "repost-of": validationSchemas["repost-of"],
+          },
+        ],
+      },
+      {
+        type: "like",
+        name: "Like",
+        h: "entry",
+        fields: [
+          "like-of",
+          "category",
+          "content",
+          "post-status",
+          "published",
+          "visibility",
+        ],
+        requiredFields: ["like-of", "published"],
+        validationSchema: [
+          {
+            "like-of": validationSchemas["like-of"],
+          },
+        ],
+      },
+    ],
     preset: undefined,
     slugSeparator: "-",
     storeMessageTemplate: (metaData) =>
diff --git a/packages/indiekit/lib/post-types.js b/packages/indiekit/lib/post-types.js
index 188f7ddc..9d014cd0 100644
--- a/packages/indiekit/lib/post-types.js
+++ b/packages/indiekit/lib/post-types.js
@@ -3,21 +3,25 @@ import _ from "lodash";
 /**
  * Get merged preset and custom post types
  * @param {object} publication - Publication configuration
- * @param {Array} publication.postTypes - Publication post types
- * @param {object} publication.preset - Publication preset
+ * @param {Array[object]} publication.postTypes - Publication post types
+ * @param {object} [publication.preset] - Publication preset
  * @returns {object} Merged configuration
  */
 export const getPostTypes = ({ postTypes, preset }) => {
-  if (preset?.postTypes && postTypes) {
-    return _.values(
-      _.merge(_.keyBy(preset.postTypes, "type"), _.keyBy(postTypes, "type")),
-    );
+  postTypes = _.keyBy(postTypes, "type");
+
+  if (preset?.postTypes) {
+    const presetPostTypes = _.keyBy(preset.postTypes, "type");
+
+    postTypes = _.merge(postTypes, presetPostTypes);
   }
 
-  // Add post type name if not provided
+  // Add fallback values to post type if not provided
   for (const key of Object.keys(postTypes)) {
-    const { name, type } = postTypes[key];
+    const { fields, name, requiredFields, type } = postTypes[key];
     postTypes[key].name = name || _.upperFirst(type);
+    postTypes[key].fields = fields || [];
+    postTypes[key].requiredFields = requiredFields || [];
   }
 
   return _.values(postTypes);
diff --git a/packages/util/index.js b/packages/util/index.js
index 23ed2646..7458317f 100644
--- a/packages/util/index.js
+++ b/packages/util/index.js
@@ -7,5 +7,7 @@ export {
   getTimeZoneOffset,
 } from "./lib/date.js";
 export { sanitise } from "./lib/object.js";
+export { ISO_6709_RE } from "./lib/regex.js";
 export { randomString, slugify, supplant } from "./lib/string.js";
 export { getCanonicalUrl, isSameOrigin } from "./lib/url.js";
+export { validationSchemas } from "./lib/validation-schema.js";
diff --git a/packages/util/lib/regex.js b/packages/util/lib/regex.js
new file mode 100644
index 00000000..8b78ff19
--- /dev/null
+++ b/packages/util/lib/regex.js
@@ -0,0 +1,7 @@
+/**
+ * Geographic point location by coordinates (ISO 6709)
+ * @example 50.8211,-0.1452 => matches
+ * @see {@link https://en.wikipedia.org/wiki/ISO_6709}
+ */
+export const ISO_6709_RE =
+  /^(?<latitude>(?:-?|\+?)?\d+(?:\.\d+)?),\s*(?<longitude>(?:-?|\+?)?\d+(?:\.\d+)?)$/;
diff --git a/packages/util/lib/validation-schema.js b/packages/util/lib/validation-schema.js
new file mode 100644
index 00000000..a35a144b
--- /dev/null
+++ b/packages/util/lib/validation-schema.js
@@ -0,0 +1,106 @@
+import { ISO_6709_RE } from "./regex.js";
+
+export const validationSchemas = {
+  "audio.*": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.media.empty`, "/music/audio.mp3"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "audio",
+    },
+    notEmpty: true,
+  },
+  content: {
+    errorMessage: (value, { req }) => req.__("posts.error.content.empty"),
+    exists: {
+      if: (value, { req }) =>
+        ["article", "note", "reply"].includes(req.body?.postType),
+    },
+    notEmpty: true,
+  },
+  "bookmark-of": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.url.empty`, "https://example.org"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "bookmark",
+    },
+    isURL: true,
+  },
+  geo: {
+    errorMessage: (value, { req }) => req.__(`posts.error.geo.invalid`),
+    exists: {
+      if: (value, { req }) => req.body?.geo,
+    },
+    custom: {
+      options: (value) => value.match(ISO_6709_RE),
+    },
+  },
+  "like-of": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.url.empty`, "https://example.org"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "like",
+    },
+    isURL: true,
+  },
+  "in-reply-to": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.url.empty`, "https://example.org"),
+    exists: {
+      if: (value, { req }) => ["reply", "rsvp"].includes(req.body?.postType),
+    },
+    isURL: true,
+  },
+  name: {
+    errorMessage: (value, { req }) => req.__("posts.error.name.empty"),
+    exists: {
+      if: (value, { req }) =>
+        ["article", "bookmark", "event"].includes(req.body?.postType),
+    },
+    notEmpty: true,
+  },
+  "photo.*.url": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.media.empty`, "/photos/image.jpg"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "photo",
+    },
+    notEmpty: true,
+  },
+  "photo.*.alt": {
+    errorMessage: (value, { req }) => req.__(`posts.error.mp-photo-alt.empty`),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "photo",
+    },
+    notEmpty: true,
+  },
+  "repost-of": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.url.empty`, "https://example.org"),
+    exists: {
+      if: (value, { req }) => ["repost"].includes(req.body?.postType),
+    },
+    isURL: true,
+  },
+  rsvp: {
+    errorMessage: (value, { req }) => req.__("posts.error.rsvp.empty"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "rsvp",
+    },
+    notEmpty: true,
+  },
+  start: {
+    errorMessage: (value, { req }) => req.__("posts.error.start.empty"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "event",
+    },
+    notEmpty: true,
+  },
+  "video.*": {
+    errorMessage: (value, { req }) =>
+      req.__(`posts.error.media.empty`, "/movies/video.mp4"),
+    exists: {
+      if: (value, { req }) => req.body?.postType === "video",
+    },
+    notEmpty: true,
+  },
+};


Test patch:
diff --git a/packages/endpoint-micropub/test/unit/post-type-discovery.js b/packages/endpoint-micropub/test/unit/post-type-discovery.js
index d8eae17e..876cb820 100644
--- a/packages/endpoint-micropub/test/unit/post-type-discovery.js
+++ b/packages/endpoint-micropub/test/unit/post-type-discovery.js
@@ -3,8 +3,13 @@ import { describe, it } from "node:test";
 import { getPostType } from "../../lib/post-type-discovery.js";
 
 describe("endpoint-media/lib/post-type-discovery", () => {
+  const postTypes = [
+    { type: "audio", discovery: "audio" },
+    { type: "bookmark", discovery: "bookmark-of" },
+  ];
+
   it("Discovers note post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       content: "Note content",
     });
@@ -13,7 +18,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers note post type (with name)", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Note title",
       content: "Note title: Note content",
@@ -23,7 +28,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers note post type (with summary)", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Note title",
       summary: "Note summary",
@@ -33,7 +38,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers article post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Article title",
       content: "Article content",
@@ -43,7 +48,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers article post type (with HTML)", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Article title",
       content: {
@@ -55,7 +60,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers article post type (with plaintext)", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Article title",
       content: {
@@ -67,7 +72,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers article post type (with HTML and plaintext)", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Article title",
       content: {
@@ -80,7 +85,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers article post type (with summary)", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Article title",
       summary: "Article summary",
@@ -94,7 +99,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers photo post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Photo title",
       photo: ["https://website.example/photo.jpg"],
@@ -104,7 +109,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers video post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Video title",
       video: ["https://website.example/video.mp4"],
@@ -114,7 +119,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers audio post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Audio title",
       audio: ["https://website.example/audio.mp3"],
@@ -124,7 +129,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers like post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Like title",
       "like-of": "https://website.example",
@@ -134,7 +139,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers repost post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Repost title",
       "repost-of": "https://website.example",
@@ -144,7 +149,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers bookmark post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Bookmark title",
       "bookmark-of": "https://website.example",
@@ -153,19 +158,8 @@ describe("endpoint-media/lib/post-type-discovery", () => {
     assert.equal(result, "bookmark");
   });
 
-  it("Discovers quotation post type", () => {
-    const result = getPostType({
-      type: "entry",
-      name: "Quotation title",
-      "quotation-of": "https://website.example",
-      content: "Quotation content",
-    });
-
-    assert.equal(result, "quotation");
-  });
-
   it("Discovers rsvp post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Event title",
       rsvp: "yes",
@@ -176,7 +170,7 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers reply post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Reply title",
       "in-reply-to": "https://website.example",
@@ -186,48 +180,8 @@ describe("endpoint-media/lib/post-type-discovery", () => {
     assert.equal(result, "reply");
   });
 
-  it("Discovers watch post type", () => {
-    const result = getPostType({
-      type: "entry",
-      name: "Watch title",
-      "watch-of": "https://website.example/video.mp4",
-    });
-
-    assert.equal(result, "watch");
-  });
-
-  it("Discovers listen post type", () => {
-    const result = getPostType({
-      type: "entry",
-      name: "Listen title",
-      "listen-of": "https://website.example/audio.mp3",
-    });
-
-    assert.equal(result, "listen");
-  });
-
-  it("Discovers read post type", () => {
-    const result = getPostType({
-      type: "entry",
-      name: "Read title",
-      "read-of": "https://website.example/article",
-    });
-
-    assert.equal(result, "read");
-  });
-
-  it("Discovers checkin post type", () => {
-    const result = getPostType({
-      type: "entry",
-      name: "Checkin title",
-      checkin: "https://website.example/place",
-    });
-
-    assert.equal(result, "checkin");
-  });
-
   it("Discovers collection post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "entry",
       name: "Collection title",
       children: [
@@ -240,22 +194,11 @@ describe("endpoint-media/lib/post-type-discovery", () => {
   });
 
   it("Discovers event post type", () => {
-    const result = getPostType({
+    const result = getPostType(postTypes, {
       type: "event",
       name: "Event title",
     });
 
     assert.equal(result, "event");
   });
-
-  it("Discovers jam post type", () => {
-    const result = getPostType({
-      type: "entry",
-      name: "Jam title",
-      "jam-of":
-        "https://music.apple.com/gb/album/love-story-taylors-version/1552791073?i=1552791427",
-    });
-
-    assert.equal(result, "jam");
-  });
 });
diff --git a/packages/endpoint-posts/test/integration/422-post-create.js b/packages/endpoint-posts/test/integration/422-post-create.js
index c160a5b2..f827d92e 100644
--- a/packages/endpoint-posts/test/integration/422-post-create.js
+++ b/packages/endpoint-posts/test/integration/422-post-create.js
@@ -14,7 +14,7 @@ describe("endpoint-posts POST /posts/create", () => {
       .post("/posts/create")
       .set("cookie", testCookie())
       .send({ type: "entry" })
-      .send({ "post-type": "note" })
+      .send({ postType: "note" })
       .send({ geo: "foobar" });
     const dom = new JSDOM(response.text);
     const result = dom.window.document;
diff --git a/packages/endpoint-posts/test/unit/utils.js b/packages/endpoint-posts/test/unit/utils.js
index b3331c53..ff01bb8d 100644
--- a/packages/endpoint-posts/test/unit/utils.js
+++ b/packages/endpoint-posts/test/unit/utils.js
@@ -7,7 +7,7 @@ import {
   getLocationProperty,
   getPostName,
   getPostStatusBadges,
-  getPostTypeName,
+  getPostTypeConfig,
   getPostUrl,
   getSyndicateToItems,
 } from "../../lib/utils.js";
@@ -218,8 +218,11 @@ describe("endpoint-posts/lib/utils", () => {
   });
 
   it("Gets post type name (or an empty string)", () => {
-    assert.equal(getPostTypeName(publication, "article"), "Journal entry");
-    assert.equal(getPostTypeName(publication, ""), "");
+    assert.deepEqual(getPostTypeConfig(publication, "article"), {
+      type: "article",
+      name: "Journal entry",
+    });
+    assert.equal(getPostTypeConfig(publication, ""), "");
   });
 
   it("Gets post URL", () => {
diff --git a/packages/indiekit/test/unit/post-types.js b/packages/indiekit/test/unit/post-types.js
index 32383ded..25137a91 100644
--- a/packages/indiekit/test/unit/post-types.js
+++ b/packages/indiekit/test/unit/post-types.js
@@ -12,18 +12,20 @@ describe("indiekit/lib/post-types", () => {
     });
     const indiekit = await Indiekit.initialize({ config });
     const { publication } = await indiekit.bootstrap();
-    const result = getPostTypes(publication);
+    const result = getPostTypes(publication)[0];
 
-    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}",
-      },
+    assert.equal(result.name, "Article");
+    assert.equal(result.type, "article");
+    assert.equal(result.h, "entry");
+    assert.equal(result.fields.length, 8);
+    assert.equal(result.requiredFields.length, 3);
+    assert.ok(result.validationSchema);
+    assert.deepEqual(result.post, {
+      path: "_posts/{yyyy}-{MM}-{dd}-{slug}.md",
+      url: "{yyyy}/{MM}/{dd}/{slug}",
+    });
+    assert.deepEqual(result.media, {
+      path: "media/{yyyy}/{MM}/{dd}/{filename}",
     });
   });
 
@@ -33,14 +35,19 @@ describe("indiekit/lib/post-types", () => {
     const { publication } = await indiekit.bootstrap();
     const result = getPostTypes(publication);
 
-    assert.equal(result[0].name, "Custom note post type");
+    assert.equal(result[1].name, "Custom note post type");
   });
 
-  it("Returns array if no preset or custom post types", async () => {
-    const config = await testConfig({ usePostTypes: false });
+  it("Returns default if no preset or custom post types", async () => {
+    const config = await testConfig({
+      usePostTypes: false,
+      usePreset: false,
+    });
     const indiekit = await Indiekit.initialize({ config });
     const { publication } = await indiekit.bootstrap();
+    const result = getPostTypes(publication);
 
-    assert.deepEqual(getPostTypes(publication), []);
+    assert.equal(result[0].name, "Article");
+    assert.equal(result[10].name, "Like");
   });
 });
diff --git a/packages/preset-hugo/test/index.js b/packages/preset-hugo/test/index.js
index c7775241..b4f66a4b 100644
--- a/packages/preset-hugo/test/index.js
+++ b/packages/preset-hugo/test/index.js
@@ -38,8 +38,8 @@ describe("preset-hugo", async () => {
 
   it("Gets publication post types", () => {
     assert.deepEqual(hugo.postTypes[0].post, {
-      path: "content/puppies/{slug}.md",
-      url: "puppies/{slug}",
+      path: "content/articles/{slug}.md",
+      url: "articles/{slug}",
     });
   });
 
diff --git a/packages/preset-jekyll/test/index.js b/packages/preset-jekyll/test/index.js
index e5dae444..a6768021 100644
--- a/packages/preset-jekyll/test/index.js
+++ b/packages/preset-jekyll/test/index.js
@@ -31,8 +31,8 @@ describe("preset-jekyll", async () => {
 
   it("Gets publication post types", () => {
     assert.deepEqual(jekyll.postTypes[0].post, {
-      path: "_puppies/{yyyy}-{MM}-{dd}-{slug}.md",
-      url: "puppies/{yyyy}/{MM}/{dd}/{slug}",
+      path: "_posts/{yyyy}-{MM}-{dd}-{slug}.md",
+      url: "{yyyy}/{MM}/{dd}/{slug}",
     });
   });