@geekity/cms 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +26 -25
  2. package/admin/components/public-admin-bar.njk +3 -1
  3. package/admin/pages/documents/editor.njk +257 -192
  4. package/admin/static/admin.css +37 -14
  5. package/dist/admin/documents.d.ts +5 -3
  6. package/dist/admin/documents.d.ts.map +1 -1
  7. package/dist/admin/documents.js +76 -20
  8. package/dist/admin/documents.js.map +1 -1
  9. package/dist/admin/editor-layout.d.ts +13 -0
  10. package/dist/admin/editor-layout.d.ts.map +1 -0
  11. package/dist/admin/editor-layout.js +61 -0
  12. package/dist/admin/editor-layout.js.map +1 -0
  13. package/dist/admin/enclosure-field.d.ts +2 -3
  14. package/dist/admin/enclosure-field.d.ts.map +1 -1
  15. package/dist/admin/enclosure-field.js +27 -7
  16. package/dist/admin/enclosure-field.js.map +1 -1
  17. package/dist/admin/location-field.d.ts +4 -3
  18. package/dist/admin/location-field.d.ts.map +1 -1
  19. package/dist/admin/location-field.js +10 -2
  20. package/dist/admin/location-field.js.map +1 -1
  21. package/dist/admin/photo-field.d.ts +2 -3
  22. package/dist/admin/photo-field.d.ts.map +1 -1
  23. package/dist/admin/photo-field.js +4 -2
  24. package/dist/admin/photo-field.js.map +1 -1
  25. package/dist/admin/read-field.d.ts +2 -3
  26. package/dist/admin/read-field.d.ts.map +1 -1
  27. package/dist/admin/read-field.js +12 -4
  28. package/dist/admin/read-field.js.map +1 -1
  29. package/dist/content/kept-properties.d.ts +5 -0
  30. package/dist/content/kept-properties.d.ts.map +1 -0
  31. package/dist/content/kept-properties.js +12 -0
  32. package/dist/content/kept-properties.js.map +1 -0
  33. package/dist/content/location.d.ts +4 -0
  34. package/dist/content/location.d.ts.map +1 -1
  35. package/dist/content/location.js +35 -3
  36. package/dist/content/location.js.map +1 -1
  37. package/dist/content/locations.d.ts +2 -5
  38. package/dist/content/locations.d.ts.map +1 -1
  39. package/dist/content/locations.js +2 -77
  40. package/dist/content/locations.js.map +1 -1
  41. package/dist/content/permalink-file.d.ts +7 -0
  42. package/dist/content/permalink-file.d.ts.map +1 -0
  43. package/dist/content/permalink-file.js +77 -0
  44. package/dist/content/permalink-file.js.map +1 -0
  45. package/dist/federation/article.d.ts +4 -3
  46. package/dist/federation/article.d.ts.map +1 -1
  47. package/dist/federation/article.js +32 -13
  48. package/dist/federation/article.js.map +1 -1
  49. package/dist/federation/citations.d.ts +7 -6
  50. package/dist/federation/citations.d.ts.map +1 -1
  51. package/dist/federation/citations.js +44 -28
  52. package/dist/federation/citations.js.map +1 -1
  53. package/dist/federation/delivery.d.ts.map +1 -1
  54. package/dist/federation/delivery.js +23 -16
  55. package/dist/federation/delivery.js.map +1 -1
  56. package/dist/index.d.ts +5 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/indieauth/bearer.d.ts +10 -6
  60. package/dist/indieauth/bearer.d.ts.map +1 -1
  61. package/dist/indieauth/bearer.js +11 -9
  62. package/dist/indieauth/bearer.js.map +1 -1
  63. package/dist/indieauth/token.js +1 -1
  64. package/dist/indieauth/token.js.map +1 -1
  65. package/dist/micropub/create.d.ts +7 -3
  66. package/dist/micropub/create.d.ts.map +1 -1
  67. package/dist/micropub/create.js +62 -16
  68. package/dist/micropub/create.js.map +1 -1
  69. package/dist/micropub/endpoint.d.ts +4 -5
  70. package/dist/micropub/endpoint.d.ts.map +1 -1
  71. package/dist/micropub/endpoint.js +15 -8
  72. package/dist/micropub/endpoint.js.map +1 -1
  73. package/dist/micropub/media.d.ts +6 -4
  74. package/dist/micropub/media.d.ts.map +1 -1
  75. package/dist/micropub/media.js +7 -5
  76. package/dist/micropub/media.js.map +1 -1
  77. package/dist/micropub/update.d.ts +7 -1
  78. package/dist/micropub/update.d.ts.map +1 -1
  79. package/dist/micropub/update.js +32 -7
  80. package/dist/micropub/update.js.map +1 -1
  81. package/dist/web/admin-bar.d.ts.map +1 -1
  82. package/dist/web/admin-bar.js +25 -0
  83. package/dist/web/admin-bar.js.map +1 -1
  84. package/dist/web/documents.d.ts +21 -6
  85. package/dist/web/documents.d.ts.map +1 -1
  86. package/dist/web/documents.js +23 -12
  87. package/dist/web/documents.js.map +1 -1
  88. package/dist/web/render.d.ts +5 -4
  89. package/dist/web/render.d.ts.map +1 -1
  90. package/dist/web/render.js +20 -11
  91. package/dist/web/render.js.map +1 -1
  92. package/dist/web/routes.d.ts.map +1 -1
  93. package/dist/web/routes.js +15 -4
  94. package/dist/web/routes.js.map +1 -1
  95. package/dist/webmention/fetch-public.d.ts +3 -1
  96. package/dist/webmention/fetch-public.d.ts.map +1 -1
  97. package/dist/webmention/fetch-public.js +17 -11
  98. package/dist/webmention/fetch-public.js.map +1 -1
  99. package/dist/webmention/reply-context.d.ts +18 -5
  100. package/dist/webmention/reply-context.d.ts.map +1 -1
  101. package/dist/webmention/reply-context.js +118 -19
  102. package/dist/webmention/reply-context.js.map +1 -1
  103. package/dist/webmention/reply-contexts.d.ts +17 -14
  104. package/dist/webmention/reply-contexts.d.ts.map +1 -1
  105. package/dist/webmention/reply-contexts.js +32 -21
  106. package/dist/webmention/reply-contexts.js.map +1 -1
  107. package/package.json +9 -9
  108. package/themes/default/README.md +31 -8
  109. package/themes/default/layouts/post.njk +5 -6
  110. package/themes/default/partials/citations.njk +12 -3
  111. package/themes/default/partials/cited-page.njk +41 -0
  112. package/themes/default/partials/post-list.njk +8 -5
  113. package/themes/default/partials/reply-context.njk +2 -9
@@ -6,11 +6,37 @@
6
6
 
7
7
  {% block title %}{{ heading }}{% endblock %}
8
8
 
9
+ {% import "components/fields.njk" as field %}
10
+
11
+ {# The legend repeats the summary so every box in it is still announced with
12
+ the name of its group. #}
13
+ {% macro group(name, open, class='admin-editor-group') %}
14
+ <details class="{{ class }}"{% if open %} open{% endif %}>
15
+ <summary>{{ name }}</summary>
16
+ <fieldset>
17
+ <legend class="admin-visually-hidden">{{ name }}</legend>
18
+ {{ caller() }}
19
+ </fieldset>
20
+ </details>
21
+ {% endmacro %}
22
+
23
+ {% macro invalid(id, hint='') -%}
24
+ {% if errorField == id %} aria-invalid="true" aria-describedby="{{ id }}-error{% if hint %} {{ hint }}{% endif %}"{% elif hint %} aria-describedby="{{ hint }}"{% endif %}
25
+ {%- endmacro %}
26
+
27
+ {% macro problem(id) %}
28
+ {% if errorField == id %}<p class="admin-field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
29
+ {% endmacro %}
30
+
9
31
  {% block content %}
10
32
  <h1>{{ heading }}</h1>
11
33
 
12
- {% if error %}
13
- <p class="admin-error">{{ error }}</p>
34
+ {% if error and errorField %}
35
+ {% call field.summary('Nothing was saved. Fix the field marked below and try again.') %}
36
+ {{ field.problem(errorField, error) }}
37
+ {% endcall %}
38
+ {% elif error %}
39
+ {{ field.summary(error) }}
14
40
  {% endif %}
15
41
 
16
42
  {% if trashed %}
@@ -33,7 +59,8 @@
33
59
  <div class="admin-editor-main">
34
60
  <label for="editor-title">Title</label>
35
61
  {#- A post may go without one, and is then a note; a page is always named. -#}
36
- <input id="editor-title" name="title" type="text" value="{{ form.title }}"{% if kind.type == "page" %} required{% endif %} autofocus />
62
+ <input id="editor-title" name="title" type="text" value="{{ form.title }}"{% if kind.type == "page" %} required{% endif %} autofocus{{ invalid('editor-title') }} />
63
+ {{ problem('editor-title') }}
37
64
  {% if kind.type == "post" %}
38
65
  <p class="admin-hint">Leave it empty to write a note: the post is then its words, and takes its slug from the first of them.</p>
39
66
  {% endif %}
@@ -64,39 +91,40 @@
64
91
  </div>
65
92
 
66
93
  {% if kind.type == "post" %}
67
- <fieldset class="admin-editor-enclosure admin-editor-photos">
68
- <legend>Photos</legend>
94
+ {% call group('Photos', open.photos) %}
69
95
  <p class="admin-hint">
70
96
  Each is an image in the media library or an address starting https://. Leave the alt text
71
97
  empty to use the media library’s. Empty an address to remove that photo.
72
98
  </p>
73
99
  {% for row in photoRows %}
74
100
  {% set n = loop.index0 %}
75
- <fieldset class="admin-editor-alternate">
76
- <legend>{% if row.url %}Photo {{ loop.index }}{% else %}Add a photo{% endif %}</legend>
101
+ {% call group('Photo ' + loop.index if row.url else 'Add a photo', row.url, 'admin-editor-group admin-editor-row') %}
77
102
  <label for="editor-photo-url-{{ n }}">Address</label>
78
- <input id="editor-photo-url-{{ n }}" name="{{ photoFields.url }}{{ n }}" type="text" value="{{ row.url }}" list="editor-photo-uploads" autocomplete="off" />
103
+ <input id="editor-photo-url-{{ n }}" name="{{ photoFields.url }}{{ n }}" type="text" value="{{ row.url }}" list="editor-photo-uploads" autocomplete="off"{{ invalid('editor-photo-url-' + n) }} />
104
+ {{ problem('editor-photo-url-' + n) }}
79
105
  <label for="editor-photo-alt-{{ n }}">Alt text</label>
80
106
  <input id="editor-photo-alt-{{ n }}" name="{{ photoFields.alt }}{{ n }}" type="text" value="{{ row.alt }}"{% if row.libraryAlt %} placeholder="{{ row.libraryAlt }}"{% endif %} />
81
- </fieldset>
107
+ {% endcall %}
82
108
  {% endfor %}
83
109
  <datalist id="editor-photo-uploads">
84
110
  {% for url in photoChoices %}
85
111
  <option value="{{ url }}"></option>
86
112
  {% endfor %}
87
113
  </datalist>
88
- </fieldset>
89
- <fieldset class="admin-editor-enclosure admin-editor-location">
90
- <legend>Location</legend>
114
+ {% endcall %}
115
+
116
+ {% call group('Location', open.location) %}
91
117
  <p class="admin-hint">
92
118
  Kept in <code>data/locations.json</code>, not in the post’s file. What readers see of it
93
119
  is chosen on Settings &gt; Privacy. Empty every box to remove it.
94
120
  </p>
95
121
  <label for="editor-location-geo">Coordinates</label>
96
- <input id="editor-location-geo" name="{{ locationFields.geo }}" type="text" value="{{ form.location.geo }}" placeholder="48.85837, 2.29448" autocomplete="off" spellcheck="false" aria-describedby="editor-location-geo-hint" />
122
+ <input id="editor-location-geo" name="{{ locationFields.geo }}" type="text" value="{{ form.location.geo }}" placeholder="48.85837, 2.29448" autocomplete="off" spellcheck="false"{{ invalid('editor-location-geo', 'editor-location-geo-hint') }} />
123
+ {{ problem('editor-location-geo') }}
97
124
  <p class="admin-hint" id="editor-location-geo-hint">Latitude, longitude, as decimal degrees. A <code>geo:</code> URI works too.</p>
98
125
  <label for="editor-location-accuracy">Accuracy</label>
99
- <input id="editor-location-accuracy" name="{{ locationFields.accuracy }}" type="text" inputmode="numeric" value="{{ form.location.accuracy }}" autocomplete="off" aria-describedby="editor-location-accuracy-hint" />
126
+ <input id="editor-location-accuracy" name="{{ locationFields.accuracy }}" type="text" inputmode="numeric" value="{{ form.location.accuracy }}" autocomplete="off"{{ invalid('editor-location-accuracy', 'editor-location-accuracy-hint') }} />
127
+ {{ problem('editor-location-accuracy') }}
100
128
  <p class="admin-hint" id="editor-location-accuracy-hint">Metres. Leave it empty if you do not know.</p>
101
129
  <label for="editor-location-name">Place</label>
102
130
  <input id="editor-location-name" name="{{ locationFields.name }}" type="text" value="{{ form.location.name }}" />
@@ -106,126 +134,205 @@
106
134
  <input id="editor-location-region" name="{{ locationFields.region }}" type="text" value="{{ form.location.region }}" />
107
135
  <label for="editor-location-country">Country</label>
108
136
  <input id="editor-location-country" name="{{ locationFields.country }}" type="text" value="{{ form.location.country }}" />
109
- </fieldset>
110
- <fieldset class="admin-editor-enclosure">
111
- <legend>Recording</legend>
137
+ <p class="admin-check">
138
+ <input id="editor-location-checkin" name="{{ locationFields.checkin }}" type="checkbox" value="1"{% if form.location.checkin %} checked{% endif %} aria-describedby="editor-location-checkin-hint" />
139
+ <label for="editor-location-checkin">A check-in</label>
140
+ </p>
141
+ <p class="admin-hint" id="editor-location-checkin-hint">A Micropub app sent it as a checkin, and reads it back as one. Readers see it as any other location.</p>
142
+ {% endcall %}
112
143
 
144
+ {% call group('Recording', open.recording) %}
113
145
  <label for="editor-enclosure-url">Audio or video file</label>
114
- <select id="editor-enclosure-url" name="{{ enclosureFields.url }}" aria-describedby="editor-enclosure-url-hint">
146
+ <select id="editor-enclosure-url" name="{{ enclosureFields.url }}"{{ invalid('editor-enclosure-url', 'editor-enclosure-url-hint') }}>
115
147
  <option value=""{% if form.enclosure.url == "" %} selected{% endif %}>None</option>
116
148
  {% for choice in enclosureChoices.media %}
117
149
  <option value="{{ choice.url }}"{% if choice.url == form.enclosure.url %} selected{% endif %}>{{ choice.label }}</option>
118
150
  {% endfor %}
119
151
  </select>
152
+ {{ problem('editor-enclosure-url') }}
120
153
  <p class="admin-hint" id="editor-enclosure-url-hint">
121
154
  Upload the file to the media library first. Feeds carry it as the post’s enclosure and the
122
155
  post page plays it. None removes the recording, its transcript and its other versions.
123
156
  </p>
124
157
 
125
158
  <label for="editor-enclosure-duration">Duration</label>
126
- <input id="editor-enclosure-duration" name="{{ enclosureFields.duration }}" type="text" inputmode="numeric" value="{{ form.enclosure.duration }}" autocomplete="off" aria-describedby="editor-enclosure-duration-hint" />
159
+ <input id="editor-enclosure-duration" name="{{ enclosureFields.duration }}" type="text" inputmode="numeric" value="{{ form.enclosure.duration }}" autocomplete="off"{{ invalid('editor-enclosure-duration', 'editor-enclosure-duration-hint') }} />
160
+ {{ problem('editor-enclosure-duration') }}
127
161
  <p class="admin-hint" id="editor-enclosure-duration-hint">Seconds, or 30:34, or 1:02:03. Leave it empty if you do not know.</p>
128
162
 
129
163
  <label for="editor-enclosure-transcript">Transcript or captions</label>
130
- <input id="editor-enclosure-transcript" name="{{ enclosureFields.transcriptUrl }}" type="text" value="{{ form.enclosure.transcriptUrl }}" list="editor-transcript-uploads" autocomplete="off" aria-describedby="editor-enclosure-transcript-hint" />
164
+ <input id="editor-enclosure-transcript" name="{{ enclosureFields.transcriptUrl }}" type="text" value="{{ form.enclosure.transcriptUrl }}" list="editor-transcript-uploads" autocomplete="off"{{ invalid('editor-enclosure-transcript', 'editor-enclosure-transcript-hint') }} />
165
+ {{ problem('editor-enclosure-transcript') }}
131
166
  <datalist id="editor-transcript-uploads">
132
167
  {% for url in enclosureChoices.transcripts %}
133
168
  <option value="{{ url }}"></option>
134
169
  {% endfor %}
135
170
  </datalist>
136
171
  <label for="editor-enclosure-transcript-type">Transcript type</label>
137
- <select id="editor-enclosure-transcript-type" name="{{ enclosureFields.transcriptType }}" aria-describedby="editor-enclosure-transcript-hint">
172
+ <select id="editor-enclosure-transcript-type" name="{{ enclosureFields.transcriptType }}"{{ invalid('editor-enclosure-transcript-type', 'editor-enclosure-transcript-hint') }}>
138
173
  <option value=""{% if form.enclosure.transcriptType == "" %} selected{% endif %}>From the file</option>
139
174
  {% for type in transcriptTypes %}
140
175
  <option value="{{ type }}"{% if type == form.enclosure.transcriptType %} selected{% endif %}>{{ type }}</option>
141
176
  {% endfor %}
142
177
  </select>
178
+ {{ problem('editor-enclosure-transcript-type') }}
143
179
  <p class="admin-hint" id="editor-enclosure-transcript-hint">
144
180
  A .vtt, .srt or .txt file in the media library, or an address starting https://. A linked
145
181
  transcript needs its type.
146
182
  </p>
147
183
 
148
- <h2 class="admin-editor-enclosure-heading">Other versions</h2>
149
- <p class="admin-hint">
150
- A video of an audio episode, a smaller file, another language. Each is a file in the media
151
- library or an address starting https://, which needs its type. Empty an address to remove
152
- that version.
153
- </p>
154
- {% for row in alternateRows %}
155
- {% set n = loop.index0 %}
156
- <fieldset class="admin-editor-alternate">
157
- <legend>{% if row.url %}Version {{ loop.index }}{% else %}Add a version{% endif %}</legend>
158
- <label for="editor-alternate-url-{{ n }}">Address</label>
159
- <input id="editor-alternate-url-{{ n }}" name="{{ enclosureFields.alternate.url }}{{ n }}" type="text" value="{{ row.url }}" list="editor-media-uploads" autocomplete="off" />
160
- <label for="editor-alternate-type-{{ n }}">Type</label>
161
- <input id="editor-alternate-type-{{ n }}" name="{{ enclosureFields.alternate.type }}{{ n }}" type="text" value="{{ row.type }}" placeholder="video/mp4" autocomplete="off" spellcheck="false" />
162
- <label for="editor-alternate-title-{{ n }}">Title</label>
163
- <input id="editor-alternate-title-{{ n }}" name="{{ enclosureFields.alternate.title }}{{ n }}" type="text" value="{{ row.title }}" maxlength="32" />
164
- <label for="editor-alternate-height-{{ n }}">Height</label>
165
- <input id="editor-alternate-height-{{ n }}" name="{{ enclosureFields.alternate.height }}{{ n }}" type="text" inputmode="numeric" value="{{ row.height }}" autocomplete="off" />
166
- <label for="editor-alternate-lang-{{ n }}">Language</label>
167
- <input id="editor-alternate-lang-{{ n }}" name="{{ enclosureFields.alternate.lang }}{{ n }}" type="text" value="{{ row.lang }}" autocomplete="off" spellcheck="false" />
168
- </fieldset>
169
- {% endfor %}
170
- <datalist id="editor-media-uploads">
171
- {% for choice in enclosureChoices.media %}
172
- <option value="{{ choice.url }}"></option>
184
+ {% call group('Other versions', open.versions) %}
185
+ <p class="admin-hint">
186
+ A video of an audio episode, a smaller file, another language. Each is a file in the media
187
+ library or an address starting https://, which needs its type. Empty an address to remove
188
+ that version.
189
+ </p>
190
+ {% for row in alternateRows %}
191
+ {% set n = loop.index0 %}
192
+ {% call group('Version ' + loop.index if row.url else 'Add a version', row.url, 'admin-editor-group admin-editor-row') %}
193
+ <label for="editor-alternate-url-{{ n }}">Address</label>
194
+ <input id="editor-alternate-url-{{ n }}" name="{{ enclosureFields.alternate.url }}{{ n }}" type="text" value="{{ row.url }}" list="editor-media-uploads" autocomplete="off"{{ invalid('editor-alternate-url-' + n) }} />
195
+ {{ problem('editor-alternate-url-' + n) }}
196
+ <label for="editor-alternate-type-{{ n }}">Type</label>
197
+ <input id="editor-alternate-type-{{ n }}" name="{{ enclosureFields.alternate.type }}{{ n }}" type="text" value="{{ row.type }}" placeholder="video/mp4" autocomplete="off" spellcheck="false"{{ invalid('editor-alternate-type-' + n) }} />
198
+ {{ problem('editor-alternate-type-' + n) }}
199
+ <label for="editor-alternate-title-{{ n }}">Title</label>
200
+ <input id="editor-alternate-title-{{ n }}" name="{{ enclosureFields.alternate.title }}{{ n }}" type="text" value="{{ row.title }}" maxlength="32"{{ invalid('editor-alternate-title-' + n) }} />
201
+ {{ problem('editor-alternate-title-' + n) }}
202
+ <label for="editor-alternate-height-{{ n }}">Height</label>
203
+ <input id="editor-alternate-height-{{ n }}" name="{{ enclosureFields.alternate.height }}{{ n }}" type="text" inputmode="numeric" value="{{ row.height }}" autocomplete="off"{{ invalid('editor-alternate-height-' + n) }} />
204
+ {{ problem('editor-alternate-height-' + n) }}
205
+ <label for="editor-alternate-lang-{{ n }}">Language</label>
206
+ <input id="editor-alternate-lang-{{ n }}" name="{{ enclosureFields.alternate.lang }}{{ n }}" type="text" value="{{ row.lang }}" autocomplete="off" spellcheck="false"{{ invalid('editor-alternate-lang-' + n) }} />
207
+ {{ problem('editor-alternate-lang-' + n) }}
208
+ {% endcall %}
173
209
  {% endfor %}
174
- </datalist>
175
- </fieldset>
210
+ <datalist id="editor-media-uploads">
211
+ {% for choice in enclosureChoices.media %}
212
+ <option value="{{ choice.url }}"></option>
213
+ {% endfor %}
214
+ </datalist>
215
+ {% endcall %}
216
+ {% endcall %}
176
217
  {% endif %}
177
218
  </div>
178
219
 
179
220
  <div class="admin-editor-side">
180
- <label for="editor-slug">Slug</label>
181
- <input id="editor-slug" name="slug" type="text" value="{{ form.slug }}" />
182
-
183
- <label for="editor-permalink">Permalink</label>
184
- <input id="editor-permalink" name="permalink" type="text" value="{{ form.permalink }}" />
185
- <p class="admin-hint">Leave it empty to let the {{ kind.singular }} take the URL its slug implies.</p>
186
-
187
- {% if kind.dated %}
188
- <label for="editor-date">Date</label>
189
- <input id="editor-date" name="date" type="text" value="{{ form.date }}" />
190
- {# The file holds a UTC instant; this field is that instant as the
191
- site's own clock reads it, so the zone has to be on the screen. #}
192
- <p class="admin-hint">Wall-clock time in {{ dateZone }}. Stored as UTC.</p>
193
- {% endif %}
221
+ {% call group('Publishing', open.publishing) %}
222
+ <p class="admin-check">
223
+ <input id="editor-draft" name="draft" type="checkbox" value="1"{% if form.draft %} checked{% endif %} />
224
+ <label for="editor-draft">Draft</label>
225
+ </p>
194
226
 
195
- {% if kind.tagged %}
196
- <label for="editor-tags">Tags</label>
197
- <input id="editor-tags" name="tags" type="text" value="{{ form.tags }}" />
198
- <p class="admin-hint">Separated by commas.</p>
199
- {% endif %}
227
+ {% if kind.dated %}
228
+ <label for="editor-date">Date</label>
229
+ <input id="editor-date" name="date" type="text" value="{{ form.date }}"{{ invalid('editor-date') }} />
230
+ {{ problem('editor-date') }}
231
+ {# The file holds a UTC instant; this field is that instant as the
232
+ site's own clock reads it, so the zone has to be on the screen. #}
233
+ <p class="admin-hint">Wall-clock time in {{ dateZone }}. Stored as UTC.</p>
234
+ {% endif %}
235
+
236
+ <label for="editor-visibility">Visibility</label>
237
+ <select id="editor-visibility" name="visibility" aria-describedby="editor-visibility-hint">
238
+ {% if form.visibility.unrecognized is defined %}
239
+ <option value="{{ form.visibility.unrecognized }}" selected>{{ form.visibility.unrecognized }} (not recognized)</option>
240
+ {% endif %}
241
+ <option value="public"{% if form.visibility == "public" %} selected{% endif %}>Public</option>
242
+ <option value="unlisted"{% if form.visibility == "unlisted" %} selected{% endif %}>Unlisted</option>
243
+ </select>
244
+ <p class="admin-hint" id="editor-visibility-hint">
245
+ {% if form.visibility.unrecognized is defined %}
246
+ The front matter says <code>visibility: {{ form.visibility.unrecognized }}</code>, which this
247
+ site does not recognize. The {{ kind.singular }} is hidden until you choose Public or Unlisted:
248
+ its page answers 404 and it is not listed or sent to your followers.
249
+ {% endif %}
250
+ Unlisted writes <code>visibility: unlisted</code> into the front matter. The {{ kind.singular }}
251
+ keeps its page and still goes to your followers, but is left off the home page, archives,
252
+ feeds, the sitemap, search and llms.txt, and asks search engines not to index it.
253
+ </p>
254
+
255
+ {% if kind.type == "post" %}
256
+ {# TASK-207: shown at the top of the author's fediverse profile, from
257
+ the featured collection their actor links. #}
258
+ <p class="admin-check">
259
+ <input id="editor-pinned" name="pinned" type="checkbox" value="1"{% if form.pinned %} checked{% endif %}{{ invalid('editor-pinned') }} />
260
+ <label for="editor-pinned">Pinned</label>
261
+ </p>
262
+ {{ problem('editor-pinned') }}
263
+ <p class="admin-hint">
264
+ Writes <code>pinned:</code> and the moment you pinned it into the front matter. Pinned posts
265
+ show first on your fediverse profile, newest pin first, up to five.
266
+ </p>
267
+ {% endif %}
200
268
 
201
- {% if kind.categorised %}
202
- <label for="editor-categories">Categories</label>
203
- <input id="editor-categories" name="categories" type="text" value="{{ form.categories }}" />
204
- <p class="admin-hint">Separated by commas.</p>
269
+ {# Who the front matter says wrote this (TASK-67). A select of the site's
270
+ users rather than a free box, because after decision-14 the value is a
271
+ person rather than a string: it decides whose archive the post is on
272
+ and, once the actors land, whose followers hear about it. A file that
273
+ still names somebody who has no account keeps an option of its own, so
274
+ opening the editor cannot silently reattribute it. #}
275
+ <label for="editor-author">Author</label>
276
+ <select id="editor-author" name="author">
277
+ {% for choice in authors %}
278
+ <option value="{{ choice.value }}"{% if choice.chosen %} selected{% endif %}>{{ choice.label }}</option>
279
+ {% endfor %}
280
+ </select>
281
+ {% endcall %}
282
+
283
+ {% if kind.tagged or kind.categorised %}
284
+ {% call group('Tags and categories', open.taxonomy) %}
285
+ {% if kind.tagged %}
286
+ <label for="editor-tags">Tags</label>
287
+ <input id="editor-tags" name="tags" type="text" value="{{ form.tags }}" />
288
+ <p class="admin-hint">Separated by commas.</p>
289
+ {% endif %}
290
+
291
+ {% if kind.categorised %}
292
+ <label for="editor-categories">Categories</label>
293
+ <input id="editor-categories" name="categories" type="text" value="{{ form.categories }}" />
294
+ <p class="admin-hint">Separated by commas.</p>
295
+ {% endif %}
296
+ {% endcall %}
205
297
  {% endif %}
206
298
 
299
+ {% call group('Address', open.address) %}
300
+ <label for="editor-slug">Slug</label>
301
+ <input id="editor-slug" name="slug" type="text" value="{{ form.slug }}"{{ invalid('editor-slug') }} />
302
+ {{ problem('editor-slug') }}
303
+
304
+ <label for="editor-permalink">Permalink</label>
305
+ <input id="editor-permalink" name="permalink" type="text" value="{{ form.permalink }}" />
306
+ <p class="admin-hint">Leave it empty to let the {{ kind.singular }} take the URL its slug implies.</p>
307
+ {% endcall %}
308
+
207
309
  {% if kind.type == "post" %}
208
- {#- The mf2 `in-reply-to`: a URL here makes the post a reply, threads
209
- it in the fediverse and sends that page a webmention. -#}
210
- <label for="editor-in-reply-to">In reply to</label>
211
- <input id="editor-in-reply-to" name="in-reply-to" type="url" value="{{ form.inReplyTo }}" />
212
- <p class="admin-hint">The address of the post this one answers. Leave it empty for a post that answers nobody.</p>
213
-
214
- {#- The mf2 `like-of`, `repost-of` and `bookmark-of` (TASK-169): each
215
- cites a page the way a reply does, and sends it a webmention. -#}
216
- <label for="editor-like-of">Like of</label>
217
- <input id="editor-like-of" name="like-of" type="url" value="{{ form.likeOf }}" />
218
- <label for="editor-repost-of">Repost of</label>
219
- <input id="editor-repost-of" name="repost-of" type="url" value="{{ form.repostOf }}" />
220
- <label for="editor-bookmark-of">Bookmark of</label>
221
- <input id="editor-bookmark-of" name="bookmark-of" type="url" value="{{ form.bookmarkOf }}" />
222
- <p class="admin-hint">The address of a post this one likes, reposts or bookmarks. Leave them empty for a post that cites nothing.</p>
223
-
224
- <fieldset class="admin-editor-enclosure admin-editor-read">
225
- <legend>Read</legend>
310
+ {% call group('Responding to', open.responding) %}
311
+ {#- The mf2 `in-reply-to`: a URL here makes the post a reply, threads
312
+ it in the fediverse and sends that page a webmention. -#}
313
+ <label for="editor-in-reply-to">In reply to</label>
314
+ <input id="editor-in-reply-to" name="in-reply-to" type="url" value="{{ form.inReplyTo }}"{{ invalid('editor-in-reply-to') }} />
315
+ {{ problem('editor-in-reply-to') }}
316
+ <p class="admin-hint">The address of the post this one answers. Leave it empty for a post that answers nobody.</p>
317
+
318
+ {#- The mf2 `like-of`, `repost-of` and `bookmark-of` (TASK-169): each
319
+ cites a page the way a reply does, and sends it a webmention. -#}
320
+ <label for="editor-like-of">Like of</label>
321
+ <input id="editor-like-of" name="like-of" type="url" value="{{ form.likeOf }}"{{ invalid('editor-like-of') }} />
322
+ {{ problem('editor-like-of') }}
323
+ <label for="editor-repost-of">Repost of</label>
324
+ <input id="editor-repost-of" name="repost-of" type="url" value="{{ form.repostOf }}"{{ invalid('editor-repost-of') }} />
325
+ {{ problem('editor-repost-of') }}
326
+ <label for="editor-bookmark-of">Bookmark of</label>
327
+ <input id="editor-bookmark-of" name="bookmark-of" type="url" value="{{ form.bookmarkOf }}"{{ invalid('editor-bookmark-of') }} />
328
+ {{ problem('editor-bookmark-of') }}
329
+ <p class="admin-hint">The address of a post this one likes, reposts or bookmarks. Leave them empty for a post that cites nothing.</p>
330
+ {% endcall %}
331
+
332
+ {% call group('Read', open.read) %}
226
333
  <label for="editor-read-status">Read status</label>
227
- <select id="editor-read-status" name="{{ readFields.status }}">
228
- <option value=""{% if form.readStatus == "" %} selected{% endif %}>Not a read</option>
334
+ <select id="editor-read-status" name="{{ readFields.status }}"{{ invalid('editor-read-status') }}>
335
+ <option value=""{% if form.readStatus == "" %} selected{% endif %}>Not a read post</option>
229
336
  {% for status in readStatuses %}
230
337
  <option value="{{ status.value }}"{% if form.readStatus == status.value %} selected{% endif %}>{{ status.label }}</option>
231
338
  {% endfor %}
@@ -233,83 +340,39 @@
233
340
  <option value="{{ unrecognizedReadStatus }}" selected>{{ unrecognizedReadStatus }} (not recognized)</option>
234
341
  {% endif %}
235
342
  </select>
343
+ {{ problem('editor-read-status') }}
236
344
  <label for="editor-read-of-name">Title</label>
237
- <input id="editor-read-of-name" name="{{ readFields.name }}" type="text" value="{{ form.readOf.name }}" />
345
+ <input id="editor-read-of-name" name="{{ readFields.name }}" type="text" value="{{ form.readOf.name }}"{{ invalid('editor-read-of-name') }} />
346
+ {{ problem('editor-read-of-name') }}
238
347
  <label for="editor-read-of-author">Author</label>
239
348
  <input id="editor-read-of-author" name="{{ readFields.author }}" type="text" value="{{ form.readOf.author }}" />
240
349
  <label for="editor-read-of-uid">Identifier</label>
241
350
  <input id="editor-read-of-uid" name="{{ readFields.uid }}" type="text" value="{{ form.readOf.uid }}" placeholder="isbn:9780441478125" autocomplete="off" spellcheck="false" />
242
351
  <label for="editor-read-of-url">Address</label>
243
- <input id="editor-read-of-url" name="{{ readFields.url }}" type="url" value="{{ form.readOf.url }}" />
244
- <p class="admin-hint">A book or paper this post says its author read. An identifier is an ISBN or a DOI, written isbn:… or doi:…. Choose Not a read and empty every box to remove it. A read is described by what it says, so leave its Description empty.</p>
245
- </fieldset>
352
+ <input id="editor-read-of-url" name="{{ readFields.url }}" type="url" value="{{ form.readOf.url }}"{{ invalid('editor-read-of-url') }} />
353
+ {{ problem('editor-read-of-url') }}
354
+ <p class="admin-hint">A book or paper this post says its author read. An identifier is an ISBN or a DOI, written isbn:… or doi:…. Choose Not a read post and empty every box to remove it. A read is described by what it says, so leave its Description empty.</p>
355
+ {% endcall %}
246
356
  {% endif %}
247
357
 
248
- <label for="editor-description">Description</label>
249
- <input id="editor-description" name="description" type="text" value="{{ form.description }}" />
250
-
251
- {#- The `lang` front matter (TASK-154): a post or page written in another
252
- language than the site's says so, for screen readers, translation
253
- and fediverse language filters. -#}
254
- <label for="editor-lang">Language</label>
255
- <input id="editor-lang" name="lang" type="text" value="{{ form.lang }}" autocomplete="off" spellcheck="false" aria-describedby="editor-lang-hint" />
256
- <p class="admin-hint" id="editor-lang-hint">A language tag, such as fr or pt-BR. Leave it empty for the site’s language, {{ siteLanguage }}.</p>
257
-
258
- {# Who the front matter says wrote this (TASK-67). A select of the site's
259
- users rather than a free box, because after decision-14 the value is a
260
- person rather than a string: it decides whose archive the post is on
261
- and, once the actors land, whose followers hear about it. A file that
262
- still names somebody who has no account keeps an option of its own, so
263
- opening the editor cannot silently reattribute it. #}
264
- <label for="editor-author">Author</label>
265
- <select id="editor-author" name="author">
266
- {% for choice in authors %}
267
- <option value="{{ choice.value }}"{% if choice.chosen %} selected{% endif %}>{{ choice.label }}</option>
268
- {% endfor %}
269
- </select>
358
+ {% call group('Summary and language', open.summary) %}
359
+ <label for="editor-description">Description</label>
360
+ <input id="editor-description" name="description" type="text" value="{{ form.description }}"{{ invalid('editor-description') }} />
361
+ {{ problem('editor-description') }}
270
362
 
271
- <p class="admin-check">
272
- <input id="editor-draft" name="draft" type="checkbox" value="1"{% if form.draft %} checked{% endif %} />
273
- <label for="editor-draft">Draft</label>
274
- </p>
275
-
276
- <label for="editor-visibility">Visibility</label>
277
- <select id="editor-visibility" name="visibility" aria-describedby="editor-visibility-hint">
278
- {% if form.visibility.unrecognized is defined %}
279
- <option value="{{ form.visibility.unrecognized }}" selected>{{ form.visibility.unrecognized }} (not recognized)</option>
280
- {% endif %}
281
- <option value="public"{% if form.visibility == "public" %} selected{% endif %}>Public</option>
282
- <option value="unlisted"{% if form.visibility == "unlisted" %} selected{% endif %}>Unlisted</option>
283
- </select>
284
- <p class="admin-hint" id="editor-visibility-hint">
285
- {% if form.visibility.unrecognized is defined %}
286
- The front matter says <code>visibility: {{ form.visibility.unrecognized }}</code>, which this
287
- site does not recognize. The {{ kind.singular }} is hidden until you choose Public or Unlisted:
288
- its page answers 404 and it is not listed or sent to your followers.
289
- {% endif %}
290
- Unlisted writes <code>visibility: unlisted</code> into the front matter. The {{ kind.singular }}
291
- keeps its page and still goes to your followers, but is left off the home page, archives,
292
- feeds, the sitemap, search and llms.txt, and asks search engines not to index it.
293
- </p>
294
-
295
- {% if kind.type == "post" %}
296
- {# TASK-207: shown at the top of the author's fediverse profile, from
297
- the featured collection their actor links. #}
298
- <p class="admin-check">
299
- <input id="editor-pinned" name="pinned" type="checkbox" value="1"{% if form.pinned %} checked{% endif %} />
300
- <label for="editor-pinned">Pinned</label>
301
- </p>
302
- <p class="admin-hint">
303
- Writes <code>pinned:</code> and the moment you pinned it into the front matter. Pinned posts
304
- show first on your fediverse profile, newest pin first, up to five.
305
- </p>
306
- {% endif %}
363
+ {#- The `lang` front matter (TASK-154): a post or page written in another
364
+ language than the site's says so, for screen readers, translation
365
+ and fediverse language filters. -#}
366
+ <label for="editor-lang">Language</label>
367
+ <input id="editor-lang" name="lang" type="text" value="{{ form.lang }}" autocomplete="off" spellcheck="false"{{ invalid('editor-lang', 'editor-lang-hint') }} />
368
+ {{ problem('editor-lang') }}
369
+ <p class="admin-hint" id="editor-lang-hint">A language tag, such as fr or pt-BR. Leave it empty for the site’s language, {{ siteLanguage }}.</p>
370
+ {% endcall %}
307
371
 
308
372
  {% if kind.type == "post" and syndicationTargets.length %}
309
373
  {# TASK-155: the targets content/_data/syndicationTargets.json declares.
310
374
  A target whose tag the post carries is selected without a tick. #}
311
- <fieldset class="admin-editor-syndication">
312
- <legend>Syndicate to</legend>
375
+ {% call group('Syndicate to', open.syndication) %}
313
376
  {% for target in syndicationTargets %}
314
377
  <p class="admin-check">
315
378
  <input id="editor-{{ target.field }}" name="{{ target.field }}" type="checkbox" value="1"{% if target.checked %} checked{% endif %} />
@@ -321,47 +384,49 @@
321
384
  sends it a webmention when it is published or updated. A post tagged with a target’s tag is
322
385
  sent there without a tick.
323
386
  </p>
324
- </fieldset>
387
+ {% endcall %}
325
388
  {% endif %}
326
389
 
327
- {# What this document says about comments, if anything. Three answers
328
- rather than two: the site's own rules, always open, always closed. #}
329
- <label for="editor-comments">Comments</label>
330
- <select id="editor-comments" name="comments">
331
- <option value="{{ commentSettings.site }}"{% if form.comments == commentSettings.site %} selected{% endif %}>Follow the site settings</option>
332
- <option value="{{ commentSettings.open }}"{% if form.comments == commentSettings.open %} selected{% endif %}>Open</option>
333
- <option value="{{ commentSettings.closed }}"{% if form.comments == commentSettings.closed %} selected{% endif %}>Closed</option>
334
- </select>
335
- <p class="admin-hint">
336
- Writes <code>comments: true</code> or <code>comments: false</code> into the front matter,
337
- which beats the site's switch and its closing window in both directions. Fediverse replies
338
- are shown either way.
339
- </p>
340
-
341
- {% if kind.excludable %}
342
- {# doc-2's eleventyExcludeFromCollections: still published and still at
343
- its URL, but out of every collection a template lists from. #}
344
- <p class="admin-check">
345
- <input id="editor-exclude" name="exclude" type="checkbox" value="1"{% if form.exclude %} checked{% endif %} />
346
- <label for="editor-exclude">Hide from collections</label>
347
- </p>
348
- <p class="admin-hint">Keeps the {{ kind.singular }} out of listings that gather content, without unpublishing it.</p>
349
- {% endif %}
350
-
351
- {% if kind.contactable %}
352
- {# The page offers a contact form under its content (TASK-56). Where a
353
- message goes is the contact address on the Settings screen; it is
354
- never on this page or in its HTML. #}
355
- <p class="admin-check">
356
- <input id="editor-contact" name="contact" type="checkbox" value="1"{% if form.contact %} checked{% endif %} />
357
- <label for="editor-contact">Contact form</label>
358
- </p>
390
+ {% call group('Display and discussion', open.display) %}
391
+ {# What this document says about comments, if anything. Three answers
392
+ rather than two: the site's own rules, always open, always closed. #}
393
+ <label for="editor-comments">Comments</label>
394
+ <select id="editor-comments" name="comments">
395
+ <option value="{{ commentSettings.site }}"{% if form.comments == commentSettings.site %} selected{% endif %}>Follow the site settings</option>
396
+ <option value="{{ commentSettings.open }}"{% if form.comments == commentSettings.open %} selected{% endif %}>Open</option>
397
+ <option value="{{ commentSettings.closed }}"{% if form.comments == commentSettings.closed %} selected{% endif %}>Closed</option>
398
+ </select>
359
399
  <p class="admin-hint">
360
- Writes <code>contact: true</code> into the front matter and puts a name, email, subject
361
- and message form under this {{ kind.singular }}. Messages arrive on the Messages screen
362
- and are emailed to the contact address on Settings.
400
+ Writes <code>comments: true</code> or <code>comments: false</code> into the front matter,
401
+ which beats the site's switch and its closing window in both directions. Fediverse replies
402
+ are shown either way.
363
403
  </p>
364
- {% endif %}
404
+
405
+ {% if kind.excludable %}
406
+ {# doc-2's eleventyExcludeFromCollections: still published and still at
407
+ its URL, but out of every collection a template lists from. #}
408
+ <p class="admin-check">
409
+ <input id="editor-exclude" name="exclude" type="checkbox" value="1"{% if form.exclude %} checked{% endif %} />
410
+ <label for="editor-exclude">Hide from collections</label>
411
+ </p>
412
+ <p class="admin-hint">Keeps the {{ kind.singular }} out of listings that gather content, without unpublishing it.</p>
413
+ {% endif %}
414
+
415
+ {% if kind.contactable %}
416
+ {# The page offers a contact form under its content (TASK-56). Where a
417
+ message goes is the contact address on the Settings screen; it is
418
+ never on this page or in its HTML. #}
419
+ <p class="admin-check">
420
+ <input id="editor-contact" name="contact" type="checkbox" value="1"{% if form.contact %} checked{% endif %} />
421
+ <label for="editor-contact">Contact form</label>
422
+ </p>
423
+ <p class="admin-hint">
424
+ Writes <code>contact: true</code> into the front matter and puts a name, email, subject
425
+ and message form under this {{ kind.singular }}. Messages arrive on the Messages screen
426
+ and are emailed to the contact address on Settings.
427
+ </p>
428
+ {% endif %}
429
+ {% endcall %}
365
430
 
366
431
  <p class="admin-editor-buttons">
367
432
  {% for button in actions %}