scavold 0.2.1 → 0.4.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.
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
- import { mkdir, writeFile, readFile } from "node:fs/promises";
3
- import { SECTIONING_ELEMENTS } from "./containers.js";
2
+ import { fileURLToPath } from "node:url";
3
+ import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
4
+ import { GRID_COLUMNS, SECTIONING_ELEMENTS } from "./containers.js";
4
5
 
5
6
  /**
6
7
  * Canonical URL of the section-type grammar this manifest conforms to. The
@@ -15,188 +16,479 @@ export const SECTION_SPEC_VERSION = 0;
15
16
  export const SECTION_MANIFEST_DIR = ".cratly";
16
17
  export const SECTION_MANIFEST_FILE = "sections.json";
17
18
 
19
+ /**
20
+ * The one folder a site keeps the icons of its section controls in, one SVG file
21
+ * each, named by the file's base name. See ADR 0010 on cratly.io for why there is
22
+ * no other place an icon can come from.
23
+ */
24
+ export const ICON_DIR = "icons";
25
+
26
+ /** Names an icon may have: a base name, never a path. Mirrors the grammar's `iconName`. */
27
+ const ICON_NAME = /^[a-z0-9][a-z0-9-]*$/;
28
+
29
+ /**
30
+ * A text for the editor, in English as the fallback and in German. Further languages
31
+ * are further keys; see "Localized texts" on https://cratly.io/reference/section-types.
32
+ *
33
+ * @param {string} en
34
+ * @param {string} de
35
+ * @returns {{"*": string, de: string}}
36
+ */
37
+ function text( en, de ) {
38
+ return { "*": en, de };
39
+ }
40
+
41
+ /**
42
+ * The choice of one value among several, each with a label.
43
+ *
44
+ * @param {...[string, string, string]} entries value, English and German label of each
45
+ * @returns {Array<{value: string, label: object}>}
46
+ */
47
+ function choices( ...entries ) {
48
+ return entries.map( ( [ value, en, de ] ) => ( { value, label: text( en, de ) } ) );
49
+ }
50
+
51
+ /**
52
+ * Pages explaining the section kinds to authors, in English and German, linked by the
53
+ * editor beside their hints. Both carry the same anchor per kind; header, footer, main
54
+ * and nav share one.
55
+ */
56
+ const AUTHOR_DOCS = {
57
+ "*": "https://scavold.io/authors/sections.html",
58
+ de: "https://scavold.io/de/authors/sections.html",
59
+ };
60
+
61
+ /**
62
+ * @param {string} anchor id of the kind's heading on the pages for authors
63
+ * @returns {{"*": string, de: string}}
64
+ */
65
+ function docs( anchor ) {
66
+ return Object.fromEntries( Object.entries( AUTHOR_DOCS ).map( ( [ locale, page ] ) => [ locale, `${page}#${anchor}` ] ) );
67
+ }
68
+
69
+ /** Names of the sectioning wrappers in the editor. */
70
+ const SECTIONING_LABELS = {
71
+ article: text( "Article", "Artikel" ),
72
+ aside: text( "Aside", "Randbereich" ),
73
+ footer: text( "Footer", "Fußbereich" ),
74
+ header: text( "Header", "Kopfbereich" ),
75
+ main: text( "Main", "Hauptinhalt" ),
76
+ nav: text( "Nav", "Navigation" ),
77
+ section: text( "Section", "Abschnitt" ),
78
+ };
79
+
80
+ /** What each sectioning wrapper is for, in the editor's type picker. */
81
+ const SECTIONING_HINTS = {
82
+ article: text(
83
+ "A piece that stands on its own — a post, a news item — and would make sense elsewhere too.",
84
+ "Ein Beitrag, der für sich steht – ein Artikel, eine Meldung – und auch anderswo verständlich wäre.",
85
+ ),
86
+ aside: text(
87
+ "Content beside the main text — a note, a box of related links — that it could do without.",
88
+ "Inhalt neben dem eigentlichen Text – ein Hinweis, eine Box mit Links –, auf den er auch verzichten könnte.",
89
+ ),
90
+ footer: text(
91
+ "Closing information of the page or of the section around it, like an author or related links.",
92
+ "Abschließende Angaben zur Seite oder zum umgebenden Abschnitt, etwa Autor oder weiterführende Links.",
93
+ ),
94
+ header: text(
95
+ "Introductory content of the page or of the section around it, like a title with a lead.",
96
+ "Einleitender Inhalt der Seite oder des umgebenden Abschnitts, etwa Titel mit Vorspann.",
97
+ ),
98
+ main: text(
99
+ "The page's main content. A page has one at most, and the site's design usually provides it already.",
100
+ "Der Hauptinhalt der Seite. Eine Seite hat höchstens einen, und meist stellt ihn die Gestaltung schon bereit.",
101
+ ),
102
+ nav: text(
103
+ "A group of links for finding one's way, like a table of contents.",
104
+ "Eine Gruppe von Links zur Orientierung, etwa ein Inhaltsverzeichnis.",
105
+ ),
106
+ section: text(
107
+ "A part of the page with a topic of its own, usually opened by a heading.",
108
+ "Ein Teil der Seite mit eigenem Thema, meist mit einer Überschrift am Anfang.",
109
+ ),
110
+ };
111
+
112
+ /**
113
+ * Sectioning wrappers offering a layout of their first block — an image, usually —
114
+ * and the text after it. The variants are implemented in styles/layouts.css.
115
+ */
116
+ const LAYOUT_SECTIONS = new Set( [ "section", "article", "aside" ] );
117
+
118
+ const LAYOUT_PROP = {
119
+ type: "enum",
120
+ label: text( "Layout", "Anordnung" ),
121
+ hint: text(
122
+ "How the first block — an image, say — and the text after it are arranged. Left unset, the block stands above the text.",
123
+ "Wie der erste Block – etwa ein Bild – und der Text danach angeordnet sind. Ohne Angabe steht der Block über dem Text.",
124
+ ),
125
+ // shown for the choice of no layout, which stacks the block above the text
126
+ icon: "stacked",
127
+ values: [
128
+ { value: "float-left", label: text( "Text flowing around it, block on the left", "Text umfließt, Block links" ), icon: "float-left" },
129
+ { value: "float-right", label: text( "Text flowing around it, block on the right", "Text umfließt, Block rechts" ), icon: "float-right" },
130
+ { value: "split-left", label: text( "Side by side, block on the left", "Nebeneinander, Block links" ), icon: "split-left" },
131
+ { value: "split-right", label: text( "Side by side, block on the right", "Nebeneinander, Block rechts" ), icon: "split-right" },
132
+ ],
133
+ };
134
+
18
135
  /**
19
136
  * Editor-facing schema for Scavold's built-in section kinds. Emitted into each
20
137
  * website's `.cratly/sections.json` so the cratly editor can render the right
21
- * controls when an author inserts or edits a section.
138
+ * controls when an author inserts or edits a section. Every text comes in English
139
+ * and German; a test keeps both complete.
22
140
  *
23
141
  * Keep the `video` props in sync with `videoProps` in
24
142
  * composables/useVideo.js — that composable defines the runtime (data-*) props;
25
143
  * this object describes the same options in the editor's typed vocabulary. The
26
- * same goes for `cover` and `coverProps` in composables/useCover.js.
144
+ * same goes for `cover` and `coverProps` in composables/useCover.js, for
145
+ * `details` and `detailsProps` in composables/useDetails.js, for `grid` and
146
+ * `gridProps` in composables/useGrid.js, and for `card` and `cardProps` in
147
+ * composables/useCard.js.
27
148
  */
28
149
  export const BUILTIN_SECTIONS = {
29
- // The seven sectioning wrappers are generic content containers with no
30
- // named props of their own; authors may still add ad-hoc flags / key-value
31
- // arguments, which the editor surfaces only when declared in config.
150
+ // The seven sectioning wrappers are generic content containers; authors may
151
+ // still add ad-hoc flags / key-value arguments, which the editor surfaces only
152
+ // when declared in config. Those holding running content offer a layout.
32
153
  ...Object.fromEntries(
33
154
  [...SECTIONING_ELEMENTS].map( name => [ name, {
34
- label: name[0].toUpperCase() + name.slice( 1 ),
155
+ label: SECTIONING_LABELS[name],
156
+ hint: SECTIONING_HINTS[name],
157
+ docs: docs( LAYOUT_SECTIONS.has( name ) ? name : "landmarks" ),
158
+ ...LAYOUT_SECTIONS.has( name ) && { props: { layout: LAYOUT_PROP } },
35
159
  } ] ),
36
160
  ),
37
161
 
38
162
  video: {
39
- label: "Video",
40
- hint: "Embedded video with playback controls.",
163
+ label: text( "Video", "Video" ),
164
+ hint: text( "Embedded video with playback controls.", "Eingebettetes Video mit Bedienelementen." ),
165
+ docs: docs( "video" ),
41
166
  props: {
42
- src: { type: "media-file", label: "Video file", required: true },
43
- poster: { type: "media-file", label: "Poster image" },
167
+ src: { type: "media-file", label: text( "Video file", "Videodatei" ), required: true },
168
+ poster: { type: "media-file", label: text( "Poster image", "Vorschaubild" ) },
44
169
  autoplay: {
45
170
  type: "boolean",
46
- label: "Autoplay (muted)",
47
- hint: "Not for visitors who asked for reduced motion. For a video across the whole window, use a cover instead.",
171
+ label: text( "Autoplay (muted)", "Automatisch abspielen (stumm)" ),
172
+ hint: text(
173
+ "Not for visitors who asked for reduced motion. For a video across the whole window, use a cover instead.",
174
+ "Nicht für Besucher, die weniger Bewegung wünschen. Für ein Video über das ganze Fenster eignet sich ein Cover.",
175
+ ),
48
176
  default: false,
49
177
  },
50
- loop: { type: "boolean", label: "Loop" },
51
- muted: { type: "boolean", label: "Muted" },
178
+ loop: { type: "boolean", label: text( "Loop", "Endlos wiederholen" ) },
179
+ muted: { type: "boolean", label: text( "Muted", "Stumm" ) },
52
180
  preload: {
53
181
  type: "enum",
54
- label: "Preload",
55
- values: [ "none", "metadata", "auto" ],
182
+ label: text( "Preload", "Vorab laden" ),
183
+ values: choices(
184
+ [ "none", "Nothing", "Nichts" ],
185
+ [ "metadata", "Metadata only", "Nur Metadaten" ],
186
+ [ "auto", "Whole video", "Ganzes Video" ],
187
+ ),
56
188
  default: "metadata",
57
189
  },
58
190
  label: {
59
191
  type: "text",
60
- label: "Accessible label",
61
- hint: "Announced by screen readers when the surrounding text does not describe the video.",
192
+ label: text( "Accessible label", "Zugängliche Bezeichnung" ),
193
+ hint: text(
194
+ "Announced by screen readers when the surrounding text does not describe the video.",
195
+ "Wird von Screenreadern angesagt, wenn der umgebende Text das Video nicht beschreibt.",
196
+ ),
62
197
  },
63
198
  },
64
199
  },
65
200
 
66
201
  cover: {
67
- label: "Cover",
68
- hint: "An image or a video across the full width and height of the window. Anything written inside the section is laid on top of it.",
202
+ label: text( "Cover", "Cover" ),
203
+ hint: text(
204
+ "An image or a video across the full width and height of the window. Anything written inside the section is laid on top of it.",
205
+ "Ein Bild oder Video über die volle Breite und Höhe des Fensters. Was im Abschnitt steht, liegt darüber.",
206
+ ),
207
+ docs: docs( "cover" ),
69
208
  props: {
70
209
  src: {
71
210
  type: "media-file",
72
- label: "Image or video",
73
- hint: "A video plays muted in a loop, with a button to pause it.",
211
+ label: text( "Image or video", "Bild oder Video" ),
212
+ hint: text(
213
+ "A video plays muted in a loop, with a button to pause it.",
214
+ "Ein Video läuft stumm in Schleife, mit einem Knopf zum Anhalten.",
215
+ ),
74
216
  required: true,
75
217
  },
76
218
  poster: {
77
219
  type: "media-file",
78
- label: "Still image",
79
- hint: "Only for a video: shown until it plays, and instead of it for visitors who asked for reduced motion.",
220
+ label: text( "Still image", "Standbild" ),
221
+ hint: text(
222
+ "Only for a video: shown until it plays, and instead of it for visitors who asked for reduced motion.",
223
+ "Nur bei einem Video: zu sehen, bis es läuft, und an seiner Stelle für Besucher, die weniger Bewegung wünschen.",
224
+ ),
80
225
  },
81
226
  focus: {
82
227
  type: "text",
83
- label: "Focal point",
84
- hint: "Part of the picture that stays visible when the window crops it, e.g. \"center top\" or \"30% 60%\".",
228
+ label: text( "Focal point", "Fokuspunkt" ),
229
+ hint: text(
230
+ "Part of the picture that stays visible when the window crops it, e.g. \"center top\" or \"30% 60%\".",
231
+ "Bildteil, der sichtbar bleibt, wenn das Fenster beschneidet, z. B. \"center top\" oder \"30% 60%\".",
232
+ ),
85
233
  },
86
234
  label: {
87
235
  type: "text",
88
- label: "Description",
89
- hint: "Alternative text of the image, or what the video shows. Leave empty when it is decoration only.",
236
+ label: text( "Description", "Beschreibung" ),
237
+ hint: text(
238
+ "Alternative text of the image, or what the video shows. Leave empty when it is decoration only.",
239
+ "Alternativtext des Bildes oder was das Video zeigt. Leer lassen, wenn es nur Dekoration ist.",
240
+ ),
90
241
  },
91
242
  tone: {
92
243
  type: "enum",
93
- label: "Content on top",
94
- values: [ "light", "dark" ],
95
- hint: "\"light\" over a dark picture, \"dark\" over a bright one. Left unset, the site's design decides.",
244
+ label: text( "Content on top", "Inhalt darüber" ),
245
+ values: choices(
246
+ [ "light", "Light, over a dark picture", "Hell, über dunklem Bild" ],
247
+ [ "dark", "Dark, over a bright picture", "Dunkel, über hellem Bild" ],
248
+ ),
249
+ hint: text( "Left unset, the site's design decides.", "Ohne Angabe entscheidet die Gestaltung der Website." ),
250
+ },
251
+ },
252
+ },
253
+
254
+ card: {
255
+ label: text( "Card", "Karte" ),
256
+ hint: text(
257
+ "A unit of its own — an image, a heading and a few lines, say — set apart from the text around it. Cards side by side go into a grid.",
258
+ "Eine Einheit für sich – etwa Bild, Überschrift und ein paar Zeilen –, abgesetzt vom Text drumherum. Karten nebeneinander kommen in ein Raster.",
259
+ ),
260
+ docs: docs( "card" ),
261
+ props: {
262
+ link: {
263
+ type: "page-ref",
264
+ label: text( "Links to", "Verlinkt auf" ),
265
+ hint: text(
266
+ "Makes the whole card a link to this page, with a \"Read more\" naming it for screen readers.",
267
+ "Macht die ganze Karte zum Link auf diese Seite, mit einem „Weiterlesen“, das sie für Screenreader benennt.",
268
+ ),
269
+ },
270
+ },
271
+ },
272
+
273
+ details: {
274
+ label: text( "Details", "Ausklappbar" ),
275
+ hint: text(
276
+ "Content that is collapsed under a title until the reader expands it — for answers in a FAQ, say, or information only some readers need.",
277
+ "Inhalt, der unter einem Titel zugeklappt ist, bis man ihn aufklappt – etwa Antworten in einer FAQ oder Hinweise, die nur manche brauchen.",
278
+ ),
279
+ docs: docs( "details" ),
280
+ props: {
281
+ summary: {
282
+ type: "text",
283
+ label: text( "Title", "Titel" ),
284
+ hint: text(
285
+ "Always visible; clicking it expands or collapses the content.",
286
+ "Immer sichtbar; ein Klick darauf klappt den Inhalt auf oder zu.",
287
+ ),
288
+ required: true,
289
+ },
290
+ open: { type: "boolean", label: text( "Expanded at first", "Anfangs aufgeklappt" ) },
291
+ group: {
292
+ type: "text",
293
+ label: text( "Group", "Gruppe" ),
294
+ hint: text(
295
+ "Blocks with the same group name close each other, so only one of them is expanded at a time.",
296
+ "Blöcke mit demselben Gruppennamen schließen einander, sodass immer nur einer aufgeklappt ist.",
297
+ ),
298
+ },
299
+ },
300
+ },
301
+
302
+ grid: {
303
+ label: text( "Grid", "Raster" ),
304
+ hint: text(
305
+ "Puts the blocks written inside side by side in columns, starting a new row when a row is full — a paragraph, an image or an embedded section each fill one cell. On narrow screens the grid drops columns by itself.",
306
+ "Stellt die Blöcke darin in Spalten nebeneinander und beginnt eine neue Zeile, wenn eine voll ist – ein Absatz, ein Bild oder ein eingebetteter Abschnitt füllt je eine Zelle. Auf schmalen Bildschirmen werden es von selbst weniger Spalten.",
307
+ ),
308
+ docs: docs( "grid" ),
309
+ props: {
310
+ columns: {
311
+ type: "enum",
312
+ label: text( "Columns", "Spalten" ),
313
+ values: GRID_COLUMNS.map( String ),
314
+ default: String( GRID_COLUMNS[0] ),
315
+ hint: text(
316
+ "Most cells side by side, where the screen is wide enough.",
317
+ "Höchstens so viele Zellen nebeneinander, wo der Bildschirm breit genug ist.",
318
+ ),
96
319
  },
97
320
  },
98
321
  },
99
322
 
100
323
  pagelist: {
101
- label: "Page list",
102
- hint: "Links a configurable number of the site's own pages — the latest posts of a blog, for example. Anything written inside the section is rendered above the list.",
324
+ label: text( "Page list", "Seitenliste" ),
325
+ hint: text(
326
+ "Links a configurable number of the site's own pages — the latest posts of a blog, for example. Anything written inside the section is rendered above the list.",
327
+ "Verlinkt eine einstellbare Zahl eigener Seiten – etwa die neuesten Beiträge eines Blogs. Was im Abschnitt steht, erscheint über der Liste.",
328
+ ),
329
+ docs: docs( "pagelist" ),
103
330
  props: {
104
331
  from: {
105
332
  type: "number",
106
- label: "Pages to list",
107
- hint: "Relative to this page: 1 = its sub-pages (the usual choice), 0 = its siblings.",
333
+ label: text( "Pages to list", "Welche Seiten" ),
334
+ hint: text(
335
+ "Relative to this page: 1 = its sub-pages (the usual choice), 0 = its siblings.",
336
+ "Ausgehend von dieser Seite: 1 = ihre Unterseiten (der übliche Fall), 0 = ihre Nachbarseiten.",
337
+ ),
108
338
  default: 1,
109
339
  },
110
340
  "from-root": {
111
341
  type: "number",
112
- label: "Level from the site root",
113
- hint: "Alternative to the relative choice: 1 = top-level pages, 2 = second level. Takes precedence over it.",
342
+ label: text( "Level from the site root", "Ebene ab Website-Start" ),
343
+ hint: text(
344
+ "Alternative to the relative choice: 1 = top-level pages, 2 = second level. Takes precedence over it.",
345
+ "Statt der relativen Wahl: 1 = oberste Ebene, 2 = zweite Ebene. Hat Vorrang davor.",
346
+ ),
114
347
  },
115
348
  "from-path": {
116
349
  type: "page-ref",
117
- label: "Pages below this page",
118
- hint: "Lists the sub-pages of the page you pick, wherever this section sits. Takes precedence over both other choices.",
350
+ label: text( "Pages below this page", "Unterseiten dieser Seite" ),
351
+ hint: text(
352
+ "Lists the sub-pages of the page you pick, wherever this section sits. Takes precedence over both other choices.",
353
+ "Listet die Unterseiten der gewählten Seite, egal wo dieser Abschnitt steht. Hat Vorrang vor beiden anderen Angaben.",
354
+ ),
119
355
  },
120
356
  deep: {
121
357
  type: "boolean",
122
- label: "Include deeper levels",
123
- hint: "Also lists pages further down, flattened into one list — for posts filed in year or month folders.",
358
+ label: text( "Include deeper levels", "Tiefere Ebenen einbeziehen" ),
359
+ hint: text(
360
+ "Also lists pages further down, flattened into one list — for posts filed in year or month folders.",
361
+ "Listet auch Seiten weiter unten, in einer flachen Liste – für Beiträge in Jahres- oder Monatsordnern.",
362
+ ),
124
363
  },
125
364
  limit: {
126
365
  type: "number",
127
- label: "Number of entries",
128
- hint: "0 lists every page found. Left unset on a list with entries per page, every page found is paged through.",
366
+ label: text( "Number of entries", "Anzahl Einträge" ),
367
+ hint: text(
368
+ "0 lists every page found. Left unset on a list with entries per page, every page found is paged through.",
369
+ "0 listet alle gefundenen Seiten. Ohne Angabe, aber mit Einträgen pro Seite, wird durch alle geblättert.",
370
+ ),
129
371
  default: 5,
130
372
  },
131
373
  offset: {
132
374
  type: "number",
133
- label: "Skip entries",
134
- hint: "Leaves out this many entries at the start — a second list can continue where the first one ended.",
375
+ label: text( "Skip entries", "Einträge überspringen" ),
376
+ hint: text(
377
+ "Leaves out this many entries at the start — a second list can continue where the first one ended.",
378
+ "Lässt so viele Einträge am Anfang weg – eine zweite Liste kann dort weitermachen, wo die erste endet.",
379
+ ),
135
380
  default: 0,
136
381
  },
137
382
  sort: {
138
383
  type: "enum",
139
- label: "Order",
140
- values: [ "order", "date", "date-asc", "title" ],
384
+ label: text( "Order", "Reihenfolge" ),
385
+ values: choices(
386
+ [ "order", "As in the site structure", "Wie in der Seitenstruktur" ],
387
+ [ "date", "Newest first", "Neueste zuerst" ],
388
+ [ "date-asc", "Oldest first", "Älteste zuerst" ],
389
+ [ "title", "By title", "Nach Titel" ],
390
+ ),
141
391
  default: "order",
142
- hint: "\"order\" follows the site structure; \"date\" is newest first and needs a date in the target pages.",
392
+ hint: text(
393
+ "Ordering by date needs a date in the listed pages.",
394
+ "Die Sortierung nach Datum braucht ein Datum in den gelisteten Seiten.",
395
+ ),
143
396
  },
144
- reverse: { type: "boolean", label: "Reverse the order" },
397
+ reverse: { type: "boolean", label: text( "Reverse the order", "Reihenfolge umkehren" ) },
145
398
  teaser: {
146
399
  type: "boolean",
147
- label: "Show introductory texts",
148
- hint: "Each target page contributes its excerpt — from its own excerpt field, or its opening lines.",
400
+ label: text( "Show introductory texts", "Einleitungstexte zeigen" ),
401
+ hint: text(
402
+ "Each target page contributes its excerpt — from its own excerpt field, or its opening lines.",
403
+ "Jede Zielseite steuert ihren Auszug bei – aus ihrem Auszugsfeld oder ihren ersten Zeilen.",
404
+ ),
149
405
  },
150
406
  images: {
151
407
  type: "boolean",
152
- label: "Show teaser images",
153
- hint: "Each target page contributes its image field, or the first image in the page.",
408
+ label: text( "Show teaser images", "Teaserbilder zeigen" ),
409
+ hint: text(
410
+ "Each target page contributes its image field, or the first image in the page.",
411
+ "Jede Zielseite steuert ihr Bildfeld bei oder das erste Bild der Seite.",
412
+ ),
154
413
  },
155
- dates: { type: "boolean", label: "Show dates" },
414
+ dates: { type: "boolean", label: text( "Show dates", "Datum zeigen" ) },
156
415
  "date-style": {
157
416
  type: "enum",
158
- label: "Date format",
159
- values: [ "short", "medium", "long", "full", "iso" ],
417
+ label: text( "Date format", "Datumsformat" ),
418
+ values: choices(
419
+ [ "short", "Short", "Kurz" ],
420
+ [ "medium", "Medium", "Mittel" ],
421
+ [ "long", "Long", "Lang" ],
422
+ [ "full", "Full, with weekday", "Voll, mit Wochentag" ],
423
+ [ "iso", "ISO (2020-10-20)", "ISO (2020-10-20)" ],
424
+ ),
160
425
  default: "long",
161
- hint: "\"iso\" writes 2020-10-20 in every language; the others follow the page's language.",
426
+ hint: text(
427
+ "ISO is the same in every language; the others follow the page's language.",
428
+ "ISO ist in jeder Sprache gleich; die anderen folgen der Sprache der Seite.",
429
+ ),
162
430
  },
163
431
  "time-style": {
164
432
  type: "enum",
165
- label: "Time format",
166
- values: [ "short", "medium", "long", "full", "iso" ],
167
- hint: "Left unset, entries show their date only. \"iso\" writes 12:16.",
433
+ label: text( "Time format", "Uhrzeitformat" ),
434
+ values: choices(
435
+ [ "short", "Short", "Kurz" ],
436
+ [ "medium", "Medium", "Mittel" ],
437
+ [ "long", "Long", "Lang" ],
438
+ [ "full", "Full", "Voll" ],
439
+ [ "iso", "ISO (12:16)", "ISO (12:16)" ],
440
+ ),
441
+ hint: text( "Left unset, entries show their date only.", "Ohne Angabe zeigen Einträge nur ihr Datum." ),
168
442
  },
169
443
  timezone: {
170
444
  type: "text",
171
- label: "Time zone",
172
- hint: "Zone the dates are read in, e.g. Europe/Berlin. Defaults to UTC, which is what a date without a time of day should be shown in.",
445
+ label: text( "Time zone", "Zeitzone" ),
446
+ hint: text(
447
+ "Zone the dates are read in, e.g. Europe/Berlin. Defaults to UTC, which is what a date without a time of day should be shown in.",
448
+ "Zone, in der Datumsangaben gelesen werden, z. B. Europe/Berlin. Standard ist UTC – richtig für ein Datum ohne Uhrzeit.",
449
+ ),
173
450
  },
174
451
  "heading-level": {
175
452
  type: "number",
176
- label: "Heading level of the titles",
177
- hint: "0 keeps the entries a plain list of links. Use 2–6 for teaser cards, one level below the heading above the list.",
453
+ label: text( "Heading level of the titles", "Überschriftenebene der Titel" ),
454
+ hint: text(
455
+ "0 keeps the entries a plain list of links. Use 2–6 for teaser cards, one level below the heading above the list.",
456
+ "0 belässt es bei einer einfachen Linkliste. 2–6 für Teaserkarten, eine Ebene unter der Überschrift über der Liste.",
457
+ ),
178
458
  default: 0,
179
459
  },
180
460
  "per-page": {
181
461
  type: "number",
182
- label: "Entries per page",
183
- hint: "0 shows all of them; anything higher lets the reader page through the list.",
462
+ label: text( "Entries per page", "Einträge pro Seite" ),
463
+ hint: text(
464
+ "0 shows all of them; anything higher lets the reader page through the list.",
465
+ "0 zeigt alle; ein höherer Wert lässt durch die Liste blättern.",
466
+ ),
184
467
  default: 0,
185
468
  },
186
469
  param: {
187
470
  type: "text",
188
- label: "Name of the page parameter",
189
- hint: "Only needed when one page carries several paginated lists, so each remembers its own page.",
471
+ label: text( "Name of the page parameter", "Name des Seitenparameters" ),
472
+ hint: text(
473
+ "Only needed when one page carries several paginated lists, so each remembers its own page.",
474
+ "Nur nötig, wenn eine Seite mehrere blätterbare Listen hat, damit sich jede ihre Seite merkt.",
475
+ ),
190
476
  },
191
477
  "image-sizes": {
192
478
  type: "text",
193
- label: "Image widths (sizes)",
194
- hint: "CSS sizes descriptor for the teaser images, matching the column they are shown in.",
479
+ label: text( "Image widths (sizes)", "Bildbreiten (sizes)" ),
480
+ hint: text(
481
+ "CSS sizes descriptor for the teaser images, matching the column they are shown in.",
482
+ "CSS-sizes-Angabe für die Teaserbilder, passend zur Spalte, in der sie stehen.",
483
+ ),
195
484
  },
196
485
  label: {
197
486
  type: "text",
198
- label: "Accessible label",
199
- hint: "Announced by screen readers to tell this list apart from other lists on the page.",
487
+ label: text( "Accessible label", "Zugängliche Bezeichnung" ),
488
+ hint: text(
489
+ "Announced by screen readers to tell this list apart from other lists on the page.",
490
+ "Wird von Screenreadern angesagt, um diese Liste von anderen auf der Seite zu unterscheiden.",
491
+ ),
200
492
  },
201
493
  },
202
494
  },
@@ -278,6 +570,10 @@ export function buildSectionManifest( declaredContainers = {}, { adapterVersion
278
570
  entry.hint = decl.hint;
279
571
  }
280
572
 
573
+ if ( decl.docs != null ) {
574
+ entry.docs = decl.docs;
575
+ }
576
+
281
577
  const props = propsFromDeclaration( decl );
282
578
  if ( Object.keys( props ).length > 0 ) {
283
579
  entry.props = { ...entry.props ?? {} , ...props };
@@ -302,6 +598,124 @@ export function buildSectionManifest( declaredContainers = {}, { adapterVersion
302
598
  };
303
599
  }
304
600
 
601
+ /**
602
+ * Lists the icons a manifest refers to, from `boolean` props and from the choices
603
+ * of `enum` props.
604
+ *
605
+ * @param {object} manifest manifest object from buildSectionManifest()
606
+ * @returns {Set<string>} referenced icon names
607
+ */
608
+ export function iconReferencesOf( manifest ) {
609
+ const names = new Set();
610
+
611
+ for ( const section of Object.values( manifest?.sections ?? {} ) ) {
612
+ for ( const prop of Object.values( section?.props ?? {} ) ) {
613
+ if ( typeof prop?.icon === "string" ) {
614
+ names.add( prop.icon );
615
+ }
616
+
617
+ for ( const choice of Array.isArray( prop?.values ) ? prop.values : [] ) {
618
+ if ( typeof choice?.icon === "string" ) {
619
+ names.add( choice.icon );
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ return names;
626
+ }
627
+
628
+ /**
629
+ * Folder of the icons Scavold brings for its own section kinds, written from
630
+ * design/icons.svg by scripts/build-icons.js.
631
+ */
632
+ export const BUILTIN_ICON_DIR = fileURLToPath( new URL( "../icons/", import.meta.url ) );
633
+
634
+ /**
635
+ * Reads the icons available to a site: Scavold's own, and those the site keeps in
636
+ * `{cwd}/.cratly/icons/`, which replace Scavold's of the same name. A file whose name
637
+ * is not a valid icon name is skipped and reported, since nothing could refer to it.
638
+ *
639
+ * @param {string} cwd project root
640
+ * @param {object} [options]
641
+ * @param {Function} [options.warn] sink for reports
642
+ * @param {string|null} [options.builtin] folder of Scavold's own icons, null for none
643
+ * @returns {Promise<Map<string,string>>} icon name → SVG markup
644
+ */
645
+ export async function readIcons( cwd, { warn = console.warn, builtin = BUILTIN_ICON_DIR } = {} ) {
646
+ const own = builtin ? await readIconDir( builtin, warn ) : new Map();
647
+ const site = await readIconDir( join( cwd, SECTION_MANIFEST_DIR, ICON_DIR ), warn );
648
+
649
+ return new Map( [ ...own, ...site ] );
650
+ }
651
+
652
+ /**
653
+ * Reads the SVG files in one folder as icons named by their base names.
654
+ *
655
+ * @param {string} dir
656
+ * @param {Function} warn sink for reports
657
+ * @returns {Promise<Map<string,string>>} icon name → SVG markup
658
+ */
659
+ async function readIconDir( dir, warn ) {
660
+ let entries;
661
+ try {
662
+ entries = await readdir( dir, { withFileTypes: true } );
663
+ } catch {
664
+ return new Map();
665
+ }
666
+
667
+ const names = [];
668
+
669
+ for ( const entry of entries ) {
670
+ if ( !entry.isFile() || !entry.name.endsWith( ".svg" ) ) {
671
+ continue;
672
+ }
673
+
674
+ const name = entry.name.slice( 0, -4 );
675
+
676
+ if ( ICON_NAME.test( name ) ) {
677
+ names.push( name );
678
+ } else {
679
+ warn( "[scavold] icon %s is ignored: a name has lower-case letters, digits and dashes only",
680
+ join( dir, entry.name ) );
681
+ }
682
+ }
683
+
684
+ const markup = await Promise.all( names.map( name => readFile( join( dir, `${name}.svg` ), "utf-8" ) ) );
685
+
686
+ return new Map( names.map( ( name, index ) => [ name, markup[index].trim() ] ) );
687
+ }
688
+
689
+ /**
690
+ * Embeds the icons a manifest refers to, so the editor gets them with the manifest.
691
+ * Icons nothing refers to stay out; a reference to an icon that does not exist is
692
+ * reported and left for the editor to fall back on its plain control.
693
+ *
694
+ * @param {object} manifest manifest object from buildSectionManifest(), adjusted in place
695
+ * @param {Map<string,string>} available icons from readIcons()
696
+ * @param {object} [options]
697
+ * @param {Function} [options.warn] sink for reports
698
+ * @returns {object} the manifest
699
+ */
700
+ export function embedIcons( manifest, available, { warn = console.warn } = {} ) {
701
+ const icons = {};
702
+
703
+ for ( const name of [...iconReferencesOf( manifest )].sort() ) {
704
+ if ( available.has( name ) ) {
705
+ icons[name] = available.get( name );
706
+ } else {
707
+ warn( "[scavold] icon '%s' is used in .cratly.config.yaml, but there is no %s/%s/%s.svg",
708
+ name, SECTION_MANIFEST_DIR, ICON_DIR, name );
709
+ }
710
+ }
711
+
712
+ if ( Object.keys( icons ).length > 0 ) {
713
+ manifest.icons = icons;
714
+ }
715
+
716
+ return manifest;
717
+ }
718
+
305
719
  /**
306
720
  * Writes a section-type manifest to `{cwd}/.cratly/sections.json`, creating the
307
721
  * folder as needed. Skips the write when the on-disk content is already